Compare commits

...
38 Commits
Author SHA1 Message Date
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
Your Name e94b1a81e3 v1.2.6 - Add MAX_MESSAGE_QUEUE_SIZE=500 limit to prevent OOM from unbounded write queue; fix wasted buf allocation in broadcast 2026-02-23 09:12:09 -04:00
Your Name 1adabdbc4e v1.2.5 - Add nip17_admin_enabled config toggle (default off) to prevent OOM from NIP-17 gift wrap decryption flood 2026-02-23 08:58:10 -04:00
Your Name 81d44c3d8c v1.2.4 - Add more characters to valid subscription characters 2026-02-11 05:56:09 -04:00
Your Name 7acc0bdd90 v1.2.3 - Handle rate limiting properly on kind 99999 request 2026-02-09 07:49:43 -04:00
Your Name c4ef71d673 v1.2.2 - Add + to allowed subscription characters 2026-02-07 13:32:00 -04:00
Your Name 086d2af56c v1.2.1 - Handle NDKs pings of kind 99999 2026-02-03 13:19:33 -04:00
Your Name 18a7deec54 v1.2.0 - Schema v11: Added event_json storage for 2500x performance improvement 2026-02-02 20:27:50 -04:00
Your Name c794370a3f v1.1.9 - Add composite index for active_subscriptions_log view optimization (schema v10) - reduces monitoring queries from 540ms to <10ms 2026-02-02 11:38:57 -04:00
Your Name 2ed4b96058 v1.1.8 - Add comprehensive database query logging with timing at debug level 3, fix schema version compatibility (v6-v9), add version logging at startup, allow monitoring throttle=0 to disable monitoring 2026-02-02 11:20:11 -04:00
Your Name c0051b22be v1.1.7 - Add per-connection database query tracking for abuse detection
Implemented comprehensive database query tracking to identify clients causing
high CPU usage through excessive database queries. The relay now tracks and
displays query statistics per WebSocket connection in the admin UI.

Features Added:
- Track db_queries_executed and db_rows_returned per connection
- Calculate query rate (queries/minute) and row rate (rows/minute)
- Display stats in admin UI grouped by IP address and WebSocket
- Show: IP, Subscriptions, Queries, Rows, Query Rate, Duration

Implementation:
- Added tracking fields to per_session_data structure
- Increment counters in handle_req_message() and handle_count_message()
- Extract stats from pss in query_subscription_details()
- Updated admin UI to display IP address and query metrics

Use Case:
Admins can now identify abusive clients by monitoring:
- High query rates (>50 queries/min indicates polling abuse)
- High row counts (>10K rows/min indicates broad filter abuse)
- Query patterns (high queries + low rows = targeted, high both = crawler)

This enables informed decisions about which IPs to blacklist based on
actual resource consumption rather than just connection count.
2026-02-01 16:26:37 -04:00
Your Name 4cc2d2376e v1.1.6 - Optimize: Deduplicate kinds in subscription index to prevent redundant operations
The kind index was adding subscriptions multiple times when filters contained
duplicate kinds (e.g., 'kinds': [1, 1, 1] or multiple filters with same kind).
This caused:
- Redundant malloc/free operations during add/remove
- Multiple index entries for same subscription+kind pair
- Excessive TRACE logging (7+ removals for single subscription)
- Wasted CPU cycles on duplicate operations

Fix:
- Added bitmap-based deduplication in add_subscription_to_kind_index()
- Uses 8KB bitmap (65536 bits) to track which kinds already added
- Prevents adding same subscription to same kind index multiple times
- Reduces index operations by 3-10x for subscriptions with duplicate kinds

Performance Impact:
- Eliminates redundant malloc/free cycles
- Reduces lock contention on kind index operations
- Decreases log volume significantly
- Should reduce CPU usage by 20-40% under production load
2026-02-01 15:59:54 -04:00
Your Name 30dc4bf67d v1.1.5 - Fix CRITICAL segfault: Use wrapper nodes for no-kind-filter subscriptions
The kind index optimization in v1.1.4 introduced a critical bug that caused
segmentation faults in production. The bug was in add_subscription_to_kind_index()
which directly assigned sub->next for no-kind-filter subscriptions, corrupting
the main active_subscriptions linked list.

Root Cause:
- subscription_t has only ONE 'next' pointer used by active_subscriptions list
- Code tried to reuse 'next' for no_kind_filter_subs list
- This overwrote the active_subscriptions linkage, breaking list traversal
- Result: segfaults when iterating subscriptions

Fix:
- Added no_kind_filter_node_t wrapper structure (like kind_subscription_node_t)
- Changed no_kind_filter_subs from subscription_t* to no_kind_filter_node_t*
- Updated add/remove functions to use wrapper nodes
- Updated broadcast function to iterate through wrapper nodes

This follows the same pattern already used for kind_index entries and
prevents any corruption of the subscription structure's next pointer.
2026-02-01 12:37:07 -04:00
Your Name a1928cc5d7 v1.1.4 - Add kind-based index for 10x subscription matching performance improvement 2026-02-01 11:15:26 -04:00
46 changed files with 22084 additions and 457 deletions
+1
View File
@@ -11,3 +11,4 @@ copy_executable_local.sh
nostr_login_lite/
style_guide/
nostr-tools
.test_keys
+5 -5
View File
@@ -107,13 +107,13 @@ COPY Makefile /build/Makefile
# Disable fortification to avoid __*_chk symbols that don't exist in MUSL
# Use conditional compilation flags based on DEBUG_BUILD argument
RUN if [ "$DEBUG_BUILD" = "true" ]; then \
CFLAGS="-g -O0 -DDEBUG"; \
STRIP_CMD=""; \
echo "Building with DEBUG symbols enabled"; \
CFLAGS="-g -O2 -DDEBUG"; \
STRIP_CMD="echo 'Keeping debug symbols'"; \
echo "Building with DEBUG symbols enabled (optimized with -O2)"; \
else \
CFLAGS="-O2"; \
STRIP_CMD="strip /build/c_relay_static"; \
echo "Building optimized production binary"; \
echo "Building optimized production binary (symbols stripped)"; \
fi && \
gcc -static $CFLAGS -Wall -Wextra -std=c99 \
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
@@ -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
+174
View File
@@ -0,0 +1,174 @@
# Real-Time Traffic Monitoring Commands (Direct Server Use)
Copy and paste these commands directly on your server.
## Quick Status Checks
### See IPs visiting in the last few minutes:
```bash
sudo tail -500 /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -20
```
### See what status codes they're getting:
```bash
sudo tail -500 /var/log/nginx/access.log | awk '{print $1, $9}' | grep '216.73.216.38'
```
### Count status codes (200 vs 403):
```bash
sudo tail -500 /var/log/nginx/access.log | awk '{print $9}' | sort | uniq -c
```
## Real-Time Monitoring
### Watch live traffic (updates every 2 seconds):
```bash
watch -n 2 'sudo tail -200 /var/log/nginx/access.log | awk "{print \$1}" | sort | uniq -c | sort -rn | head -15'
```
### See live log entries as they happen:
```bash
sudo tail -f /var/log/nginx/access.log
```
### Live GoAccess dashboard:
```bash
sudo tail -f /var/log/nginx/access.log | goaccess -
```
## Active Connections
### See who's connected RIGHT NOW:
```bash
sudo netstat -tn | grep ':443' | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn
```
### Alternative (using ss command):
```bash
sudo ss -tn | grep ':443' | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn
```
## Detailed Analysis
### Last 100 requests with timestamps:
```bash
sudo tail -100 /var/log/nginx/access.log | awk '{print $4, $1}' | sed 's/\[//'
```
### See what blocked IPs are trying to access:
```bash
sudo tail -500 /var/log/nginx/access.log | grep '216.73.216.38' | awk '{print $7}' | head -10
```
### Show all 403 (blocked) requests:
```bash
sudo tail -500 /var/log/nginx/access.log | awk '$9==403 {print $1}' | sort | uniq -c | sort -rn
```
### Show all successful (200) requests:
```bash
sudo tail -500 /var/log/nginx/access.log | awk '$9==200 {print $1}' | sort | uniq -c | sort -rn | head -10
```
## Comprehensive Monitoring Script
### Create a monitoring script:
```bash
cat > /tmp/monitor-traffic.sh << 'EOF'
#!/bin/bash
echo "=== Traffic in last 5 minutes ==="
echo "Time: $(date)"
echo ""
echo "Top IPs:"
sudo tail -1000 /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -10
echo ""
echo "Blocked IPs (403 errors):"
sudo tail -1000 /var/log/nginx/access.log | awk '$9==403 {print $1}' | sort | uniq -c | sort -rn
echo ""
echo "Successful requests (200):"
sudo tail -1000 /var/log/nginx/access.log | awk '$9==200 {print $1}' | sort | uniq -c | sort -rn | head -5
echo ""
echo "Status Code Summary:"
sudo tail -1000 /var/log/nginx/access.log | awk '{print $9}' | sort | uniq -c | sort -rn
EOF
chmod +x /tmp/monitor-traffic.sh
```
### Run the monitoring script:
```bash
/tmp/monitor-traffic.sh
```
## Auto-Refreshing Dashboard
### Live dashboard (refreshes every 5 seconds):
```bash
watch -n 5 'echo "=== Last 5 minutes ==="
date
echo ""
echo "Top IPs:"
sudo tail -1000 /var/log/nginx/access.log | awk "{print \$1}" | sort | uniq -c | sort -rn | head -10
echo ""
echo "Status Codes:"
sudo tail -1000 /var/log/nginx/access.log | awk "{print \$9}" | sort | uniq -c | sort -rn'
```
Press `Ctrl+C` to exit.
## GoAccess HTML Report (Live Updating)
### Generate live HTML report:
```bash
sudo goaccess /var/log/nginx/access.log -o /var/www/html/live-stats.html --real-time-html --daemonize
```
Then visit: https://git.laantungir.net/live-stats.html
### Stop the live report:
```bash
sudo pkill -f "goaccess.*live-stats"
```
## Filter by Time
### Get timestamp from 5 minutes ago:
```bash
date -d '5 minutes ago' '+%d/%b/%Y:%H:%M'
```
### Analyze only recent logs (replace timestamp):
```bash
sudo awk '/01\/Feb\/2026:19:09/,0' /var/log/nginx/access.log | goaccess -
```
## Check Gitea CPU
### Current CPU usage:
```bash
ps aux | grep gitea | grep -v grep
```
### Watch CPU in real-time:
```bash
watch -n 2 'ps aux | grep gitea | grep -v grep'
```
## Most Useful Command for Quick Check
This one-liner shows everything you need:
```bash
echo "=== Quick Status ===" && \
echo "Time: $(date)" && \
echo "" && \
echo "Top 10 IPs (last 1000 requests):" && \
sudo tail -1000 /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -10 && \
echo "" && \
echo "Status Codes:" && \
sudo tail -1000 /var/log/nginx/access.log | awk '{print $9}' | sort | uniq -c && \
echo "" && \
echo "Gitea CPU:" && \
ps aux | grep gitea | grep -v grep
```
Copy any of these commands and run them directly on your server!
+178
View File
@@ -1291,6 +1291,184 @@ body.dark-mode .sql-results-table tbody tr:nth-child(even) {
/* background-color: var(--secondary-color); */
}
/* ================================
WEB OF TRUST (WoT) STYLES
================================ */
.wot-status-row, .wot-stats-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
font-size: 14px;
}
.wot-indicator {
padding: 2px 10px;
border-radius: 4px;
font-weight: bold;
font-size: 12px;
}
.wot-indicator.wot-found { background: #28a745; color: white; }
.wot-indicator.wot-missing { background: #dc3545; color: white; }
.wot-indicator.wot-unknown { background: #6c757d; color: white; }
.wot-level-selector { padding: 10px 0; }
.wot-level-selector label { display: block; margin-bottom: 5px; font-weight: bold; }
.wot-level-btn { min-width: 100px; }
.wot-level-btn.active {
background: var(--accent-color, #ff0000);
color: white;
border-color: var(--accent-color, #ff0000);
}
.wot-level-description {
font-size: 12px;
color: var(--primary-color);
margin-top: 5px;
font-style: italic;
opacity: 0.8;
}
/* Dark mode adjustments for WoT */
body.dark-mode .wot-indicator.wot-found { background: #28a745; }
body.dark-mode .wot-indicator.wot-missing { background: #dc3545; }
body.dark-mode .wot-indicator.wot-unknown { background: #6c757d; }
/* ================================
ADMIN ACCESS GATE STYLES
================================ */
/* Access Denied Overlay */
.access-denied-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
z-index: 9999;
display: none;
justify-content: center;
align-items: center;
}
.access-denied-content {
background: var(--card-bg);
border: 2px solid #dc3545;
border-radius: 12px;
padding: 40px 60px;
text-align: center;
max-width: 500px;
box-shadow: 0 10px 40px rgba(220, 53, 69, 0.3);
}
.access-denied-icon {
font-size: 64px;
margin-bottom: 20px;
}
.access-denied-content h2 {
color: #dc3545;
font-size: 32px;
margin-bottom: 20px;
letter-spacing: 2px;
}
.access-denied-message {
font-size: 16px;
color: var(--primary-color);
margin-bottom: 10px;
line-height: 1.5;
}
.access-denied-submessage {
font-size: 14px;
color: var(--muted-color);
margin-bottom: 30px;
font-style: italic;
}
.access-denied-logout-btn {
background: #dc3545;
color: white;
border: none;
padding: 12px 40px;
font-size: 16px;
font-weight: bold;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
}
.access-denied-logout-btn:hover {
background: #c82333;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4);
}
/* Admin Verification Loading Overlay */
.admin-verification-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 9998;
display: none;
justify-content: center;
align-items: center;
}
.admin-verification-content {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 40px 60px;
text-align: center;
max-width: 450px;
}
.admin-verification-content h3 {
color: var(--accent-color);
font-size: 20px;
margin-bottom: 15px;
}
.admin-verification-content p {
color: var(--muted-color);
font-size: 14px;
margin-top: 15px;
}
/* Spinner Animation */
.spinner {
width: 50px;
height: 50px;
border: 4px solid var(--border-color);
border-top: 4px solid var(--accent-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Dark mode adjustments */
body.dark-mode .access-denied-content {
background: var(--card-bg);
}
body.dark-mode .admin-verification-content {
background: var(--card-bg);
}
.subscription-detail-row:hover {
background-color: var(--muted-color);
}
+150 -9
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>
@@ -67,6 +68,17 @@
</div>
</div>
<!-- Access Denied Overlay (shown when non-admin user logs in) -->
<div id="access-denied-overlay" class="access-denied-overlay" style="display: none;">
<div class="access-denied-content">
<div class="access-denied-icon"></div>
<h2>ACCESS DENIED</h2>
<p class="access-denied-message">This interface is restricted to the relay administrator.</p>
<p class="access-denied-submessage">The logged-in account does not have admin privileges.</p>
<button type="button" class="access-denied-logout-btn" onclick="logout()">LOGOUT</button>
</div>
</div>
<!-- DATABASE STATISTICS Section -->
<!-- Subscribe to kind 24567 events to receive real-time monitoring data -->
<div class="section flex-section" id="databaseStatisticsSection" style="display: none;">
@@ -251,6 +263,7 @@
</div>
<!-- Auth Rules Management - Moved after configuration -->
<!-- AUTH RULES MANAGEMENT SECTION -->
<div class="section flex-section" id="authRulesSection" style="display: none;">
<div class="section-header">
AUTH RULES MANAGEMENT
@@ -273,16 +286,11 @@ AUTH RULES MANAGEMENT
</table>
</div>
<!-- Simplified Auth Rule Input Section -->
<!-- Auth Rule Input Section -->
<div id="authRuleInputSections" style="display: block;">
<!-- Combined Pubkey Auth Rule Section -->
<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
@@ -296,10 +304,51 @@ AUTH RULES MANAGEMENT
BLACKLIST</button>
<button type="button" id="refreshAuthRulesBtn">REFRESH</button>
</div>
</div>
</div>
<!-- WEB OF TRUST SECTION -->
<div class="section flex-section" id="wotSection" style="display: none;">
<div class="section-header">
WEB OF TRUST
</div>
<!-- Kind 3 Status Indicator -->
<div id="wotKind3Status" class="wot-status-row">
<span>Admin Contact List (kind 3):</span>
<span id="wotKind3Indicator" class="wot-indicator wot-unknown">Checking...</span>
</div>
<!-- WoT Level Selector -->
<div class="wot-level-selector">
<label>WoT Level:</label>
<div class="inline-buttons">
<button type="button" id="wotLevel0Btn" class="wot-level-btn" onclick="setWotLevel(0)">
OFF
</button>
<button type="button" id="wotLevel1Btn" class="wot-level-btn" onclick="setWotLevel(1)">
WRITE ONLY
</button>
<button type="button" id="wotLevel2Btn" class="wot-level-btn" onclick="setWotLevel(2)">
FULL
</button>
</div>
<div class="wot-level-description" id="wotLevelDescription">
Level 0: Open relay — anyone can read and write
</div>
</div>
<!-- WoT Stats -->
<div class="wot-stats-row">
<span>Whitelisted Pubkeys:</span>
<span id="wotWhitelistCount"></span>
</div>
<!-- Sync Button -->
<div class="inline-buttons">
<button type="button" id="wotSyncBtn" onclick="syncWot()">SYNC FROM KIND 3</button>
<button type="button" id="wotRefreshBtn" onclick="loadWotStatus()">REFRESH STATUS</button>
</div>
</div>
@@ -334,6 +383,94 @@ AUTH RULES MANAGEMENT
</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>
<!-- 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">
@@ -419,6 +556,10 @@ AUTH RULES MANAGEMENT
<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>
+680 -27
View File
@@ -39,6 +39,11 @@ let countdownSeconds = 10;
let currentPage = 'statistics'; // Default page
let sideNavOpen = false;
// Admin verification state
let isAdminVerified = false;
let adminVerificationTimeout = null;
let pendingAdminVerification = false;
// SQL Query state
let pendingSqlQueries = new Map();
@@ -306,7 +311,10 @@ async function restoreAuthenticationState(pubkey) {
loadUserProfile();
// Automatically set up relay connection (but don't show admin sections yet)
await setupAutomaticRelayConnection();
await setupAutomaticRelayConnection(false);
// Verify admin access before showing admin sections
verifyAdminAccess();
console.log('✅ Authentication state restored successfully');
}
@@ -422,8 +430,8 @@ async function initializeApp() {
if (wasAlreadyLoggedIn) {
console.log('User was already logged in, showing profile in header');
showProfileInHeader();
// Show admin sections since user is already authenticated and relay is connected
updateAdminSectionsVisibility();
// Admin verification will be triggered by restoreAuthenticationState
// which calls verifyAdminAccess() after setting up relay connection
} else {
console.log('No existing authentication found, showing login modal');
showLoginModal();
@@ -453,11 +461,11 @@ function handleAuthEvent(event) {
showProfileInHeader();
loadUserProfile();
// Automatically set up relay connection and show admin sections
setupAutomaticRelayConnection(true);
// Automatically set up relay connection
setupAutomaticRelayConnection(false); // Don't show sections yet - wait for admin verification
// Auto-enable monitoring when admin logs in
autoEnableMonitoring();
// Verify admin access before showing admin sections
verifyAdminAccess();
} else if (error) {
console.log(`Authentication error: ${error}`);
@@ -471,11 +479,25 @@ function handleLogoutEvent() {
userPubkey = null;
isLoggedIn = false;
currentConfig = null;
isAdminVerified = false;
// Reset relay connection state
isRelayConnected = false;
relayPubkey = null;
// Clear any pending admin verification
if (adminVerificationTimeout) {
clearTimeout(adminVerificationTimeout);
adminVerificationTimeout = null;
}
pendingAdminVerification = false;
// Hide access denied overlay if showing
hideAccessDeniedOverlay();
// Hide admin verification loading if showing
hideAdminVerificationLoading();
// Reset UI - hide profile and show login modal
hideProfileFromHeader();
showLoginModal();
@@ -486,12 +508,98 @@ function handleLogoutEvent() {
console.log('Logout event handled successfully');
}
// Verify admin access by sending a system_status command and waiting for response
async function verifyAdminAccess() {
console.log('=== VERIFYING ADMIN ACCESS (BYPASS MODE) ===');
if (!isLoggedIn || !isRelayConnected) {
console.log('Cannot verify admin access - not logged in or not connected to relay');
showAccessDeniedOverlay();
return;
}
// Update visibility of admin sections based on login and relay connection status
// TEMPORARY BYPASS: Skip admin verification and grant access immediately
// TODO: Re-enable once idle_connection_timeout_sec is increased to 120+
pendingAdminVerification = false;
isAdminVerified = true;
hideAdminVerificationLoading();
updateAdminSectionsVisibility();
console.log('✅ Admin verification bypassed (temporary)');
}
// Show loading indicator while verifying admin access
function showAdminVerificationLoading() {
// Create loading overlay if it doesn't exist
let loadingOverlay = document.getElementById('admin-verification-loading');
if (!loadingOverlay) {
loadingOverlay = document.createElement('div');
loadingOverlay.id = 'admin-verification-loading';
loadingOverlay.className = 'admin-verification-overlay';
loadingOverlay.innerHTML = `
<div class="admin-verification-content">
<div class="spinner"></div>
<h3>Verifying Administrator Access...</h3>
<p id="admin-verify-status">Waiting for response from relay...</p>
</div>
`;
document.body.appendChild(loadingOverlay);
}
loadingOverlay.style.display = 'flex';
}
// Hide admin verification loading overlay
function hideAdminVerificationLoading() {
const loadingOverlay = document.getElementById('admin-verification-loading');
if (loadingOverlay) {
loadingOverlay.style.display = 'none';
}
}
// Show access denied overlay for non-admin users
function showAccessDeniedOverlay() {
const accessDeniedOverlay = document.getElementById('access-denied-overlay');
if (accessDeniedOverlay) {
accessDeniedOverlay.style.display = 'flex';
}
// Also hide any admin sections that might be showing
hideAllAdminSections();
}
// Hide access denied overlay
function hideAccessDeniedOverlay() {
const accessDeniedOverlay = document.getElementById('access-denied-overlay');
if (accessDeniedOverlay) {
accessDeniedOverlay.style.display = 'none';
}
}
// Hide all admin sections
function hideAllAdminSections() {
const sections = [
'databaseStatisticsSection',
'subscriptionDetailsSection',
'div_config',
'authRulesSection',
'wotSection',
'nip17DMSection',
'sqlQuerySection'
];
sections.forEach(sectionId => {
const section = document.getElementById(sectionId);
if (section) {
section.style.display = 'none';
}
});
}
// Update visibility of admin sections based on login, relay connection, and admin verification status
function updateAdminSectionsVisibility() {
const shouldShow = isLoggedIn && isRelayConnected;
const shouldShow = isLoggedIn && isRelayConnected && isAdminVerified;
// If logged in and connected, show the current page, otherwise hide all sections
// If logged in, connected, and admin verified, show the current page
if (shouldShow) {
// Show the current page
switchPage(currentPage);
@@ -499,12 +607,13 @@ function updateAdminSectionsVisibility() {
// Load data for the current page
loadCurrentPageData();
} else {
// Hide all sections when not logged in or not connected
// Hide all sections when not logged in, not connected, or not admin
const sections = [
'databaseStatisticsSection',
'subscriptionDetailsSection',
'div_config',
'authRulesSection',
'wotSection',
'nip17DMSection',
'sqlQuerySection'
];
@@ -543,6 +652,10 @@ function loadCurrentPageData() {
loadAuthRules().catch(error => {
console.log('Auto-load auth rules failed: ' + error.message);
});
// Load WoT status
loadWotStatus().catch(error => {
console.log('Auto-load WoT status failed: ' + error.message);
});
break;
// Other pages don't need initial data loading
}
@@ -1196,6 +1309,23 @@ async function subscribeToConfiguration() {
console.log('No configuration events were received');
}
},
async onauth(challenge) {
// NIP-42 authentication: relay sent AUTH challenge, sign and respond
console.log('🔐 NIP-42 AUTH challenge received:', challenge);
try {
if (!window.nostr) {
console.error('NIP-42 auth required but no nostr extension available');
return;
}
const url = relayConnectionUrl.value.trim();
const authEvent = window.NostrTools.nip42.makeAuthEvent(url, challenge);
const signedAuthEvent = await window.nostr.signEvent(authEvent);
console.log('✅ NIP-42 AUTH event signed, responding to challenge');
return signedAuthEvent;
} catch (authError) {
console.error('NIP-42 AUTH failed:', authError.message);
}
},
onclose(reason) {
console.log('Subscription closed:', reason);
// Reset subscription state to allow re-subscription
@@ -1717,6 +1847,33 @@ function handleSystemCommandResponse(responseData) {
console.log('Command:', responseData.command);
console.log('Status:', responseData.status);
// Handle admin verification via system_status response.
// Also handle late responses that arrive after the timeout fired (relay was under load).
if (responseData.command === 'system_status' && (pendingAdminVerification || (isLoggedIn && !isAdminVerified))) {
console.log('✅ Admin verification successful - received system_status response');
// Clear the timeout if still pending
if (adminVerificationTimeout) {
clearTimeout(adminVerificationTimeout);
adminVerificationTimeout = null;
}
pendingAdminVerification = false;
isAdminVerified = true;
// Hide loading overlay and access denied overlay (in case timeout already showed it)
hideAdminVerificationLoading();
hideAccessDeniedOverlay();
// Show admin sections now that we're verified
updateAdminSectionsVisibility();
// Auto-enable monitoring when admin logs in
autoEnableMonitoring();
log('Administrator access verified', 'INFO');
}
// Handle delete auth rule responses
if (responseData.command === 'delete_auth_rule') {
if (responseData.status === 'success') {
@@ -1741,11 +1898,224 @@ function handleSystemCommandResponse(responseData) {
}
}
// Handle WoT status and sync responses
if (responseData.command === 'wot_status' || responseData.command === 'wot_sync') {
handleWotStatusResponse(responseData);
}
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `System command response: ${responseData.command} - ${responseData.status}`, 'SYSTEM_CMD');
}
}
// ================================
// WEB OF TRUST (WoT) FUNCTIONS
// ================================
// Load WoT status from relay using admin API
async function loadWotStatus() {
try {
log('Loading WoT status via admin API...', 'INFO');
if (!isLoggedIn || !userPubkey) {
throw new Error('Must be logged in to load WoT status');
}
if (!relayPool) {
throw new Error('SimplePool connection not available');
}
// Create command array for WoT status
const command_array = ["system_command", "wot_status"];
// Encrypt the command array directly using NIP-44
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) {
throw new Error('Failed to encrypt WoT status command');
}
// Create single kind 23456 admin event
const event = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
// Sign the event
const signedEvent = await window.nostr.signEvent(event);
if (!signedEvent || !signedEvent.sig) {
throw new Error('Event signing failed');
}
log('Sending WoT status query to relay...', 'INFO');
// Publish via SimplePool
const url = relayConnectionUrl.value.trim();
await relayPool.publish([url], signedEvent);
log('WoT status query sent successfully', 'INFO');
} catch (error) {
log(`Failed to load WoT status: ${error.message}`, 'ERROR');
}
}
// Set WoT level via config_set
async function setWotLevel(level) {
try {
log(`Setting WoT level to ${level}...`, 'INFO');
if (!isLoggedIn || !userPubkey) {
throw new Error('Must be logged in to set WoT level');
}
if (!relayPool) {
throw new Error('SimplePool connection not available');
}
// Send config_set for wot_enabled
const command_array = ["config_set", "wot_enabled", String(level)];
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) {
throw new Error('Failed to encrypt config_set command');
}
// Create single kind 23456 admin event
const event = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
// Sign the event
const signedEvent = await window.nostr.signEvent(event);
if (!signedEvent || !signedEvent.sig) {
throw new Error('Event signing failed');
}
// Publish via SimplePool
const url = relayConnectionUrl.value.trim();
await relayPool.publish([url], signedEvent);
log(`WoT level set to ${level} - sync will trigger automatically`, 'INFO');
// Refresh status after a delay to allow sync to complete
setTimeout(() => loadWotStatus(), 2000);
} catch (error) {
log(`Failed to set WoT level: ${error.message}`, 'ERROR');
}
}
// Force WoT sync from admin's kind 3 event
async function syncWot() {
try {
log('Triggering WoT sync from kind 3...', 'INFO');
if (!isLoggedIn || !userPubkey) {
throw new Error('Must be logged in to sync WoT');
}
if (!relayPool) {
throw new Error('SimplePool connection not available');
}
// Create command array for WoT sync
const command_array = ["system_command", "wot_sync"];
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) {
throw new Error('Failed to encrypt WoT sync command');
}
// Create single kind 23456 admin event
const event = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
// Sign the event
const signedEvent = await window.nostr.signEvent(event);
if (!signedEvent || !signedEvent.sig) {
throw new Error('Event signing failed');
}
// Publish via SimplePool
const url = relayConnectionUrl.value.trim();
await relayPool.publish([url], signedEvent);
log('WoT sync command sent', 'INFO');
// Refresh status after a delay
setTimeout(() => loadWotStatus(), 2000);
} catch (error) {
log(`Failed to sync WoT: ${error.message}`, 'ERROR');
}
}
// Handle WoT status response and update UI
function handleWotStatusResponse(responseData) {
console.log('=== WoT STATUS RESPONSE ===', responseData);
const kind3Indicator = document.getElementById('wotKind3Indicator');
const whitelistCount = document.getElementById('wotWhitelistCount');
const levelDesc = document.getElementById('wotLevelDescription');
// Update kind 3 indicator
if (kind3Indicator) {
if (responseData.admin_kind3_exists) {
kind3Indicator.textContent = 'Found ✓';
kind3Indicator.className = 'wot-indicator wot-found';
} else {
kind3Indicator.textContent = 'Not Found ✗';
kind3Indicator.className = 'wot-indicator wot-missing';
}
}
// Update whitelist count
if (whitelistCount) {
whitelistCount.textContent = responseData.wot_whitelist_count !== undefined ? responseData.wot_whitelist_count : '—';
}
// Update level buttons
const level = responseData.wot_enabled !== undefined ? responseData.wot_enabled : 0;
['wotLevel0Btn', 'wotLevel1Btn', 'wotLevel2Btn'].forEach((id, i) => {
const btn = document.getElementById(id);
if (btn) {
if (i === level) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
}
});
// Update description
const descriptions = [
'Level 0: Open relay — anyone can read and write',
'Level 1: Write-only — only followed pubkeys can publish events',
'Level 2: Full — only followed pubkeys can publish AND subscribe (NIP-42 required)'
];
if (levelDesc) {
levelDesc.textContent = descriptions[level] || descriptions[0];
}
// Enable/disable sync button based on kind 3 existence
const syncBtn = document.getElementById('wotSyncBtn');
if (syncBtn) {
syncBtn.disabled = !responseData.admin_kind3_exists;
}
log(`WoT status: level=${level}, kind3_exists=${responseData.admin_kind3_exists}, whitelist_count=${responseData.wot_whitelist_count}`, 'INFO');
}
// Handle auth rule modification responses
function handleAuthRuleResponse(responseData) {
console.log('=== AUTH RULE MODIFICATION RESPONSE ===');
@@ -2206,6 +2576,7 @@ let editingAuthRule = null;
// DOM elements for auth rules
const authRulesSection = document.getElementById('authRulesSection');
const wotSection = document.getElementById('wotSection');
const refreshAuthRulesBtn = document.getElementById('refreshAuthRulesBtn');
const authRulesTableContainer = document.getElementById('authRulesTableContainer');
const authRulesTableBody = document.getElementById('authRulesTableBody');
@@ -2222,6 +2593,12 @@ function showAuthRulesSection() {
updateAuthRulesStatus('ready');
log('Auth rules section is now available', 'INFO');
}
// Also show WoT section
if (wotSection) {
wotSection.style.display = 'block';
loadWotStatus();
log('WoT section is now available', 'INFO');
}
}
// Hide auth rules section on logout
@@ -2241,6 +2618,11 @@ function hideAuthRulesSection() {
editingAuthRule = null;
log('Auth rules section hidden', 'INFO');
}
// Also hide WoT section
if (wotSection) {
wotSection.style.display = 'none';
log('WoT section hidden', 'INFO');
}
}
// Update auth rules status indicator (removed - no status element)
@@ -2774,7 +3156,7 @@ function addBlacklistRule() {
// Convert nsec or npub to hex if needed
const hexPubkey = nsecToHex(inputValue);
if (!hexPubkey) {
log('Invalid pubkey format. Please enter nsec1..., npub1..., or 64-character hex', 'ERROR');
log('Invalid public key format. Please enter npub1... or 64-character hex pubkey', 'ERROR');
return;
}
@@ -2826,7 +3208,7 @@ function addWhitelistRule() {
// Convert nsec or npub to hex if needed
const hexPubkey = nsecToHex(inputValue);
if (!hexPubkey) {
log('Invalid pubkey format. Please enter nsec1..., npub1..., or 64-character hex', 'ERROR');
log('Invalid public key format. Please enter npub1... or 64-character hex pubkey', 'ERROR');
return;
}
@@ -4324,6 +4706,12 @@ function populateSubscriptionDetailsTable(subscriptionsData) {
const oldestDuration = Math.max(...subscriptions.map(s => now - s.created_at));
const oldestDurationStr = formatDuration(oldestDuration);
// Calculate total query stats for this connection
const totalQueries = subscriptions.reduce((sum, s) => sum + (s.db_queries_executed || 0), 0);
const totalRows = subscriptions.reduce((sum, s) => sum + (s.db_rows_returned || 0), 0);
const avgQueryRate = subscriptions.length > 0 ? (subscriptions[0].query_rate_per_min || 0) : 0;
const clientIp = subscriptions.length > 0 ? (subscriptions[0].client_ip || 'unknown') : 'unknown';
// Create header row (summary)
const headerRow = document.createElement('tr');
headerRow.className = 'subscription-group-header';
@@ -4334,9 +4722,14 @@ function populateSubscriptionDetailsTable(subscriptionsData) {
headerRow.innerHTML = `
<td colspan="4" style="padding: 8px;">
<span class="expand-icon" style="display: inline-block; width: 20px; transition: transform 0.2s;">▶</span>
<strong style="font-family: 'Courier New', monospace; font-size: 12px;">Websocket: ${wsiPointer}</strong>
<span style="color: #666; margin-left: 15px;">
Subscriptions: ${subCount} | Oldest: ${oldestDurationStr}
<strong style="font-family: 'Courier New', monospace; font-size: 12px;">IP: ${clientIp}</strong>
<span style="color: #666; margin-left: 10px; font-size: 11px;">
WS: ${wsiPointer} |
Subs: ${subCount} |
Queries: ${totalQueries.toLocaleString()} |
Rows: ${totalRows.toLocaleString()} |
Rate: ${avgQueryRate.toFixed(1)} q/min |
Duration: ${oldestDurationStr}
</span>
</td>
`;
@@ -4717,6 +5110,8 @@ function switchPage(pageName) {
'subscriptionDetailsSection',
'div_config',
'authRulesSection',
'wotSection',
'ipBansSection',
'relayEventsSection',
'nip17DMSection',
'sqlQuerySection'
@@ -4729,22 +5124,35 @@ function switchPage(pageName) {
}
});
// Show selected section
// Show selected section(s)
const pageMap = {
'statistics': 'databaseStatisticsSection',
'subscriptions': 'subscriptionDetailsSection',
'configuration': 'div_config',
'authorization': 'authRulesSection',
'ip-bans': 'ipBansSection',
'relay-events': 'relayEventsSection',
'dm': 'nip17DMSection',
'database': 'sqlQuerySection'
};
const targetSectionId = pageMap[pageName];
if (targetSectionId) {
const targetSection = document.getElementById(targetSectionId);
if (targetSection) {
targetSection.style.display = 'block';
// Authorization page shows both authRulesSection and wotSection
if (pageName === 'authorization') {
const authSection = document.getElementById('authRulesSection');
const wotSectionEl = document.getElementById('wotSection');
if (authSection) {
authSection.style.display = 'block';
}
if (wotSectionEl) {
wotSectionEl.style.display = 'block';
loadWotStatus();
}
} else {
const targetSectionId = pageMap[pageName];
if (targetSectionId) {
const targetSection = document.getElementById(targetSectionId);
if (targetSection) {
targetSection.style.display = 'block';
}
}
}
@@ -4864,7 +5272,9 @@ const SQL_QUERY_TEMPLATES = {
subscriptions: "SELECT * FROM active_subscriptions_log ORDER BY created_at DESC",
top_pubkeys: "SELECT * FROM top_pubkeys_view",
event_kinds: "SELECT * FROM event_kinds_view ORDER BY count DESC",
time_stats: "SELECT 'total' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events UNION ALL SELECT '24h' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 86400) UNION ALL SELECT '7d' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 604800) UNION ALL SELECT '30d' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 2592000)"
time_stats: "SELECT 'total' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events UNION ALL SELECT '24h' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 86400) UNION ALL SELECT '7d' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 604800) UNION ALL SELECT '30d' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 2592000)",
banned_ips_summary: "SELECT COUNT(*) as total_ips, COUNT(CASE WHEN banned_until > strftime('%s', 'now') THEN 1 END) as currently_banned, SUM(total_failures) as total_failures, SUM(total_connections) as total_connections, SUM(ban_count) as total_bans FROM ip_bans",
banned_ips_list: "SELECT ip, failure_count, ban_count, datetime(banned_until, 'unixepoch') as banned_until, datetime(first_failure, 'unixepoch') as first_failure, datetime(last_success_at, 'unixepoch') as last_success, total_connections, total_failures, total_successes, datetime(first_seen, 'unixepoch') as first_seen FROM ip_bans ORDER BY banned_until DESC, total_failures DESC"
};
// Query history management (localStorage)
@@ -5049,9 +5459,21 @@ async function sendAdminCommand(commandArray) {
throw new Error('Event signing failed');
}
// Publish via SimplePool with detailed error diagnostics
// Publish via SimplePool with NIP-42 auth support
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
const publishPromises = relayPool.publish([url], signedEvent, {
async onauth(challenge) {
console.log('🔐 NIP-42 AUTH challenge during publish:', challenge);
try {
const authEvent = window.NostrTools.nip42.makeAuthEvent(url, challenge);
const signedAuthEvent = await window.nostr.signEvent(authEvent);
console.log('✅ NIP-42 AUTH signed for publish');
return signedAuthEvent;
} catch (authError) {
console.error('NIP-42 AUTH failed during publish:', authError.message);
}
}
});
// Use Promise.allSettled to capture per-relay outcomes
const results = await Promise.allSettled(publishPromises);
@@ -5755,4 +6177,235 @@ function initializeRelayEvents() {
}
console.log('Relay events functionality initialized');
}
}
// ================================
// IP BANS MANAGEMENT FUNCTIONS
// ================================
let ipBansFilter = 'all'; // 'all', 'banned', 'expired'
// Load IP bans from database
async function loadIpBans() {
const query = "SELECT ip, failure_count, ban_count, banned_until, first_failure, has_authed_successfully, last_success_at, total_connections, total_failures, total_successes, first_seen FROM ip_bans ORDER BY banned_until DESC, total_failures DESC";
await executeSqlQueryRaw(query, 'ip_bans_load');
}
// Execute SQL query and handle IP bans response
async function executeSqlQueryRaw(query, queryId) {
if (!pool || pool.length === 0) {
log('Not connected to relay', 'ERROR');
return;
}
const relay = pool[0];
if (!relay) {
log('No relay connection available', 'ERROR');
return;
}
// Create a kind 23456 event with SQL query
const event = {
kind: 23456,
content: query,
tags: [['t', 'sql_query'], ['id', queryId]],
created_at: Math.floor(Date.now() / 1000)
};
try {
await relay.publish(event);
log('Loading IP bans...', 'INFO');
} catch (error) {
log('Failed to load IP bans: ' + error.message, 'ERROR');
}
}
// Handle IP bans SQL response
function handleIpBansResponse(responseData) {
if (!responseData.results || responseData.results.length === 0) {
document.getElementById('ip-bans-tbody').innerHTML = '<tr><td colspan="7" style="text-align: center;">No IP bans found</td></tr>';
document.getElementById('ip-bans-total').textContent = '0';
document.getElementById('ip-bans-active').textContent = '0';
document.getElementById('ip-bans-issued').textContent = '0';
return;
}
const now = Math.floor(Date.now() / 1000);
let totalIPs = responseData.results.length;
let currentlyBanned = 0;
let totalBans = 0;
// Filter results based on current filter
let filteredResults = responseData.results;
if (ipBansFilter === 'banned') {
filteredResults = responseData.results.filter(row => row.banned_until > now);
} else if (ipBansFilter === 'expired') {
filteredResults = responseData.results.filter(row => row.banned_until <= now && row.banned_until > 0);
}
// Calculate stats
responseData.results.forEach(row => {
totalBans += parseInt(row.ban_count || 0);
if (row.banned_until > now) {
currentlyBanned++;
}
});
// Update stats
document.getElementById('ip-bans-total').textContent = totalIPs;
document.getElementById('ip-bans-active').textContent = currentlyBanned;
document.getElementById('ip-bans-issued').textContent = totalBans;
// Render table
const tbody = document.getElementById('ip-bans-tbody');
tbody.innerHTML = filteredResults.map(row => {
const isBanned = row.banned_until > now;
const status = isBanned ? '🔴 Banned' : (row.ban_count > 0 ? '🟡 Expired' : '🟢 Clean');
const bannedUntil = row.banned_until > 0 ? new Date(row.banned_until * 1000).toLocaleString() : '-';
const failures = row.total_failures || 0;
const authedSuccessfully = row.has_authed_successfully ? '✅ Yes' : '❌ No';
const connectionAttempts = row.total_connections || 0;
return `<tr>
<td>${escapeHtml(row.ip)}</td>
<td>${status}</td>
<td>${bannedUntil}</td>
<td>${failures}</td>
<td>${authedSuccessfully}</td>
<td>${connectionAttempts}</td>
<td>
${isBanned ? `<button type="button" onclick="unbanIp('${escapeHtml(row.ip)}')">Unban</button>` : ''}
<button type="button" onclick="deleteIpBan('${escapeHtml(row.ip)}')">Delete</button>
</td>
</tr>`;
}).join('');
}
// Ban an IP address
async function banIp() {
const ipInput = document.getElementById('ban-ip-input');
const durationSelect = document.getElementById('ban-duration-select');
const statusDiv = document.getElementById('add-ban-status');
const ip = ipInput.value.trim();
const duration = parseInt(durationSelect.value);
if (!ip) {
statusDiv.innerHTML = '<div class="error-message">Please enter an IP address</div>';
return;
}
// Validate IP format (basic validation)
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
if (!ipRegex.test(ip)) {
statusDiv.innerHTML = '<div class="error-message">Invalid IP address format</div>';
return;
}
const bannedUntil = Math.floor(Date.now() / 1000) + duration;
const query = `INSERT OR REPLACE INTO ip_bans (ip, banned_until, ban_count, updated_at, first_seen) VALUES ('${ip}', ${bannedUntil}, COALESCE((SELECT ban_count FROM ip_bans WHERE ip = '${ip}'), 0) + 1, strftime('%s', 'now'), COALESCE((SELECT first_seen FROM ip_bans WHERE ip = '${ip}'), strftime('%s', 'now')))`;
await executeSqlQueryRaw(query, 'ip_ban_add');
statusDiv.innerHTML = '<div class="success-message">IP banned successfully</div>';
ipInput.value = '';
// Reload the list
setTimeout(() => loadIpBans(), 500);
}
// Unban an IP address
async function unbanIp(ip) {
if (!confirm(`Are you sure you want to unban ${ip}?`)) {
return;
}
const query = `UPDATE ip_bans SET banned_until = 0, failure_count = 0 WHERE ip = '${ip}'`;
await executeSqlQueryRaw(query, 'ip_ban_unban');
// Reload the list
setTimeout(() => loadIpBans(), 500);
}
// Delete an IP ban record
async function deleteIpBan(ip) {
if (!confirm(`Are you sure you want to delete the ban record for ${ip}?`)) {
return;
}
const query = `DELETE FROM ip_bans WHERE ip = '${ip}'`;
await executeSqlQueryRaw(query, 'ip_ban_delete');
// Reload the list
setTimeout(() => loadIpBans(), 500);
}
// Set filter and reload
function setIpBansFilter(filter) {
ipBansFilter = filter;
// Update button states
document.getElementById('ip-ban-filter-all').classList.toggle('active', filter === 'all');
document.getElementById('ip-ban-filter-banned').classList.toggle('active', filter === 'banned');
document.getElementById('ip-ban-filter-expired').classList.toggle('active', filter === 'expired');
// Reload with new filter
loadIpBans();
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initialize IP Bans event listeners
function initIpBansEventListeners() {
const addBanBtn = document.getElementById('add-ban-btn');
const refreshBtn = document.getElementById('refresh-ip-bans-btn');
const filterAllBtn = document.getElementById('ip-ban-filter-all');
const filterBannedBtn = document.getElementById('ip-ban-filter-banned');
const filterExpiredBtn = document.getElementById('ip-ban-filter-expired');
if (addBanBtn) {
addBanBtn.addEventListener('click', banIp);
}
if (refreshBtn) {
refreshBtn.addEventListener('click', loadIpBans);
}
if (filterAllBtn) {
filterAllBtn.addEventListener('click', () => setIpBansFilter('all'));
}
if (filterBannedBtn) {
filterBannedBtn.addEventListener('click', () => setIpBansFilter('banned'));
}
if (filterExpiredBtn) {
filterExpiredBtn.addEventListener('click', () => setIpBansFilter('expired'));
}
console.log('IP Bans event listeners initialized');
}
// Handle SQL query responses for IP bans
const originalHandleSqlQueryResponse = handleSqlQueryResponse;
handleSqlQueryResponse = function(response) {
console.log('=== HANDLING SQL QUERY RESPONSE ===');
console.log('Response:', response);
// Check if this is an IP bans query
if (response.query_id && (response.query_id.startsWith('ip_ban') || response.query_id === 'ip_bans_load')) {
handleIpBansResponse(response);
return;
}
// Call original handler for other queries
return originalHandleSqlQueryResponse(response);
};
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', initIpBansEventListeners);
+5
View File
@@ -77,6 +77,11 @@ case "$ARCH" in
;;
esac
# Append _debug suffix to output name for debug builds so production binary is never overwritten
if [ "$DEBUG_BUILD" = true ]; then
OUTPUT_NAME="${OUTPUT_NAME}_debug"
fi
echo "Building for platform: $PLATFORM"
echo "Output binary: $OUTPUT_NAME"
echo ""
Binary file not shown.
+9970
View File
File diff suppressed because it is too large Load Diff
+5 -12
View File
@@ -1,19 +1,12 @@
#!/bin/bash
# Restart the service
sudo systemctl stop c-relay.service
# Copy the binary to the deployment location
cp build/c_relay_x86 ~/Storage/c_relay/crelay
# Copy the service file to systemd (use the main service file)
sudo cp systemd/c-relay.service /etc/systemd/system/c-relay-local.service
# Reload systemd daemon to pick up the new service
sudo systemctl daemon-reload
# Enable the service (if not already enabled)
sudo systemctl enable c-relay-local.service
cp build/c_relay_static_x86_64 ~/Storage/c_relay/crelay
# Restart the service
sudo systemctl restart c-relay-local.service
sudo systemctl restart c-relay.service
# Show service status
sudo systemctl status c-relay-local.service --no-pager -l
sudo systemctl status c-relay.service --no-pager -l
+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 ""
+20 -2
View File
@@ -70,6 +70,24 @@ while [[ $# -gt 0 ]]; do
;;
--test-keys|-t)
USE_TEST_KEYS=true
# Read keys from .test_keys file
if [ -f ".test_keys" ]; then
echo "Reading test keys from .test_keys file..."
# Source the file to get the variables
source .test_keys
# Remove any single quotes from the values
# Note: -a flag expects ADMIN_PUBKEY (public key), not ADMIN_PRIVKEY
ADMIN_KEY=$(echo "$ADMIN_PUBKEY" | tr -d "'")
RELAY_KEY=$(echo "$SERVER_PRIVKEY" | tr -d "'")
echo "Using admin pubkey from .test_keys: ${ADMIN_KEY:0:16}..."
echo "Using relay privkey from .test_keys: ${RELAY_KEY:0:16}..."
else
echo "ERROR: .test_keys file not found"
echo "Please create a .test_keys file with the following format:"
echo " ADMIN_PUBKEY='your_admin_public_key_hex'"
echo " SERVER_PRIVKEY='your_relay_private_key_hex'"
exit 1
fi
shift
;;
--debug-level=*)
@@ -336,8 +354,8 @@ fi
cd build
# Start relay in background and capture its PID
if [ "$USE_TEST_KEYS" = true ]; then
echo "Using deterministic test keys for development..."
./$(basename $BINARY_PATH) -a 6a04ab98d9e4774ad806e302dddeb63bea16b5cb5f223ee77478e861bb583eb3 -r 1111111111111111111111111111111111111111111111111111111111111111 --debug-level=$DEBUG_LEVEL --strict-port > ../relay.log 2>&1 &
echo "Using test keys from .test_keys file..."
./$(basename $BINARY_PATH) -a "$ADMIN_KEY" -r "$RELAY_KEY" --debug-level=$DEBUG_LEVEL --strict-port > ../relay.log 2>&1 &
elif [ -n "$RELAY_ARGS" ]; then
echo "Starting relay with custom configuration..."
./$(basename $BINARY_PATH) $RELAY_ARGS --debug-level=$DEBUG_LEVEL --strict-port > ../relay.log 2>&1 &
@@ -0,0 +1,579 @@
# Event JSON Storage & Database Migration Plan
**Goal:** Store full event JSON in database for 2,500x faster retrieval + implement proper database migration system
---
## Decision: Fresh Start vs Migration
### Option A: Fresh Start (Recommended for This Change)
**Pros:**
- ✅ Clean implementation (no migration complexity)
- ✅ Fast deployment (no data conversion)
- ✅ No risk of migration bugs
- ✅ Opportunity to fix any schema issues
- ✅ Smaller database (no legacy data)
**Cons:**
- ❌ Lose existing events
- ❌ Relay starts "empty"
- ❌ Historical data lost
**Recommendation:** **Fresh start for this change** because:
1. Your relay is still in development/testing phase
2. The schema change is fundamental (affects every event)
3. Migration would require reconstructing JSON for every existing event (expensive)
4. You've been doing fresh starts anyway
### Option B: Implement Migration System
**Pros:**
- ✅ Preserve existing events
- ✅ No data loss
- ✅ Professional approach
- ✅ Reusable for future changes
**Cons:**
- ❌ Complex implementation
- ❌ Slow migration (reconstruct JSON for all events)
- ❌ Risk of bugs during migration
- ❌ Requires careful testing
**Recommendation:** **Implement migration system for FUTURE changes**, but start fresh for this one.
---
## Proposed Schema Change
### New Schema (v11)
```sql
CREATE TABLE events (
id TEXT PRIMARY KEY,
pubkey TEXT NOT NULL,
created_at INTEGER NOT NULL,
kind INTEGER NOT NULL,
event_type TEXT NOT NULL CHECK (event_type IN ('regular', 'replaceable', 'ephemeral', 'addressable')),
content TEXT NOT NULL,
sig TEXT NOT NULL,
tags JSON NOT NULL DEFAULT '[]',
event_json TEXT NOT NULL, -- NEW: Full event as JSON string
first_seen INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
-- Keep all existing indexes (they query the columns, not event_json)
CREATE INDEX idx_events_pubkey ON events(pubkey);
CREATE INDEX idx_events_kind ON events(kind);
CREATE INDEX idx_events_created_at ON events(created_at DESC);
CREATE INDEX idx_events_kind_created_at ON events(kind, created_at DESC);
CREATE INDEX idx_events_pubkey_created_at ON events(pubkey, created_at DESC);
```
### Why Keep Both Columns AND event_json?
**Columns (id, pubkey, kind, etc.):**
- Used for **querying** (WHERE clauses, indexes)
- Fast filtering and sorting
- Required for SQL operations
**event_json:**
- Used for **retrieval** (SELECT results)
- Pre-serialized, ready to send
- Eliminates JSON reconstruction
**This is a common pattern** in high-performance systems (denormalization for read performance).
---
## Implementation Plan
### Phase 1: Schema Update (v11)
**File:** `src/sql_schema.h`
```c
#define EMBEDDED_SCHEMA_VERSION "11"
// In schema SQL:
"CREATE TABLE events (\n\
id TEXT PRIMARY KEY,\n\
pubkey TEXT NOT NULL,\n\
created_at INTEGER NOT NULL,\n\
kind INTEGER NOT NULL,\n\
event_type TEXT NOT NULL,\n\
content TEXT NOT NULL,\n\
sig TEXT NOT NULL,\n\
tags JSON NOT NULL DEFAULT '[]',\n\
event_json TEXT NOT NULL,\n\ -- NEW COLUMN
first_seen INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))\n\
);\n\
```
### Phase 2: Update store_event() Function
**File:** `src/main.c` (lines 660-773)
**Current:**
```c
int store_event(cJSON* event) {
// Extract fields
cJSON* id = cJSON_GetObjectItem(event, "id");
// ... extract other fields ...
// INSERT with individual columns
const char* sql = "INSERT INTO events (id, pubkey, ...) VALUES (?, ?, ...)";
}
```
**New:**
```c
int store_event(cJSON* event) {
// Serialize event to JSON string ONCE
char* event_json = cJSON_PrintUnformatted(event);
if (!event_json) {
return -1;
}
// Extract fields for indexed columns
cJSON* id = cJSON_GetObjectItem(event, "id");
// ... extract other fields ...
// INSERT with columns + event_json
const char* sql = "INSERT INTO events (id, pubkey, ..., event_json) VALUES (?, ?, ..., ?)";
// ... bind parameters ...
sqlite3_bind_text(stmt, 9, event_json, -1, SQLITE_TRANSIENT);
// ... execute ...
free(event_json);
}
```
### Phase 3: Update handle_req_message() Function
**File:** `src/main.c` (lines 1302-1361)
**Current:**
```c
while (sqlite3_step(stmt) == SQLITE_ROW) {
// Build event JSON from 7 columns
cJSON* event = cJSON_CreateObject();
cJSON_AddStringToObject(event, "id", (char*)sqlite3_column_text(stmt, 0));
// ... 6 more fields ...
cJSON* tags = cJSON_Parse(tags_json); // Parse tags
cJSON_AddItemToObject(event, "tags", tags);
// Create EVENT message
cJSON* event_msg = cJSON_CreateArray();
cJSON_AddItemToArray(event_msg, cJSON_CreateString("EVENT"));
cJSON_AddItemToArray(event_msg, cJSON_CreateString(sub_id));
cJSON_AddItemToArray(event_msg, event);
char* msg_str = cJSON_Print(event_msg);
queue_message(wsi, pss, msg_str, msg_len, LWS_WRITE_TEXT);
}
```
**New:**
```c
// Update SQL to select event_json
const char* sql = "SELECT event_json FROM events WHERE ...";
while (sqlite3_step(stmt) == SQLITE_ROW) {
const char* event_json = (char*)sqlite3_column_text(stmt, 0);
// Build EVENT message with pre-serialized event
// Format: ["EVENT","sub_id",{...event_json...}]
size_t msg_len = 12 + strlen(sub_id) + strlen(event_json); // ["EVENT","",""]
char* msg_str = malloc(msg_len + 1);
snprintf(msg_str, msg_len + 1, "[\"EVENT\",\"%s\",%s]", sub_id, event_json);
queue_message(wsi, pss, msg_str, strlen(msg_str), LWS_WRITE_TEXT);
free(msg_str);
}
```
**Speedup:** 366 × (cJSON operations) eliminated!
---
## Database Migration System Design
### For Future Schema Changes
**File:** `src/migrations.c` (new file)
```c
typedef struct {
int from_version;
int to_version;
const char* description;
int (*migrate_func)(sqlite3* db);
} migration_t;
// Migration from v10 to v11: Add event_json column
int migrate_v10_to_v11(sqlite3* db) {
// Step 1: Add column
const char* add_column_sql =
"ALTER TABLE events ADD COLUMN event_json TEXT";
if (sqlite3_exec(db, add_column_sql, NULL, NULL, NULL) != SQLITE_OK) {
return -1;
}
// Step 2: Populate event_json for existing events
const char* select_sql =
"SELECT id, pubkey, created_at, kind, content, sig, tags FROM events";
sqlite3_stmt* stmt;
if (sqlite3_prepare_v2(db, select_sql, -1, &stmt, NULL) != SQLITE_OK) {
return -1;
}
while (sqlite3_step(stmt) == SQLITE_ROW) {
// Reconstruct JSON
cJSON* event = cJSON_CreateObject();
cJSON_AddStringToObject(event, "id", (char*)sqlite3_column_text(stmt, 0));
// ... add other fields ...
char* event_json = cJSON_PrintUnformatted(event);
// Update row
const char* update_sql = "UPDATE events SET event_json = ? WHERE id = ?";
sqlite3_stmt* update_stmt;
sqlite3_prepare_v2(db, update_sql, -1, &update_stmt, NULL);
sqlite3_bind_text(update_stmt, 1, event_json, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(update_stmt, 2, (char*)sqlite3_column_text(stmt, 0), -1, SQLITE_STATIC);
sqlite3_step(update_stmt);
sqlite3_finalize(update_stmt);
free(event_json);
cJSON_Delete(event);
}
sqlite3_finalize(stmt);
// Step 3: Make column NOT NULL
// (SQLite doesn't support ALTER COLUMN, so we'd need to recreate table)
return 0;
}
// Migration registry
static migration_t migrations[] = {
{10, 11, "Add event_json column for fast retrieval", migrate_v10_to_v11},
// Future migrations go here
};
int run_migrations(sqlite3* db, int current_version, int target_version) {
for (int i = 0; i < sizeof(migrations) / sizeof(migration_t); i++) {
if (migrations[i].from_version >= current_version &&
migrations[i].to_version <= target_version) {
printf("Running migration: %s\n", migrations[i].description);
if (migrations[i].migrate_func(db) != 0) {
fprintf(stderr, "Migration failed: %s\n", migrations[i].description);
return -1;
}
// Update schema version
char update_version_sql[256];
snprintf(update_version_sql, sizeof(update_version_sql),
"PRAGMA user_version = %d", migrations[i].to_version);
sqlite3_exec(db, update_version_sql, NULL, NULL, NULL);
}
}
return 0;
}
```
---
## Recommendation: Hybrid Approach
### For This Change (v10 → v11): Fresh Start
**Rationale:**
1. Your relay is still in development
2. Migration would be slow (reconstruct JSON for all events)
3. You've been doing fresh starts anyway
4. Clean slate for performance testing
**Steps:**
1. Update schema to v11 with event_json column
2. Update store_event() to populate event_json
3. Update handle_req_message() to use event_json
4. Deploy with fresh database
5. Test performance improvement
### For Future Changes: Use Migration System
**Rationale:**
1. Once relay is in production, data preservation matters
2. Migration system is reusable
3. Professional approach for production relay
**Steps:**
1. Create `src/migrations.c` and `src/migrations.h`
2. Implement migration framework
3. Add migration functions for each schema change
4. Test migrations thoroughly before deployment
---
## Migration System Features
### Core Features
1. **Version Detection**
- Read current schema version from database
- Compare with embedded schema version
- Determine which migrations to run
2. **Migration Chain**
- Run migrations in sequence (v8 → v9 → v10 → v11)
- Skip already-applied migrations
- Stop on first failure
3. **Backup Before Migration**
- Automatic database backup before migration
- Rollback capability if migration fails
- Backup retention policy
4. **Progress Reporting**
- Log migration progress
- Show estimated time remaining
- Report success/failure
### Safety Features
1. **Transaction Wrapping**
```c
sqlite3_exec(db, "BEGIN TRANSACTION", NULL, NULL, NULL);
int result = migrate_v10_to_v11(db);
if (result == 0) {
sqlite3_exec(db, "COMMIT", NULL, NULL, NULL);
} else {
sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL);
}
```
2. **Validation After Migration**
- Verify row counts match
- Check data integrity
- Validate indexes created
3. **Dry-Run Mode**
- Test migration without committing
- Report what would be changed
- Estimate migration time
---
## Implementation Timeline
### Immediate (Today): Fresh Start with event_json
**Changes:**
1. Update schema to v11 (add event_json column)
2. Update store_event() to populate event_json
3. Update handle_req_message() to use event_json
4. Deploy with fresh database
**Effort:** 4 hours
**Impact:** 2,500x faster event retrieval
### This Week: Build Migration Framework
**Changes:**
1. Create src/migrations.c and src/migrations.h
2. Implement migration runner
3. Add backup/rollback capability
4. Add progress reporting
**Effort:** 1-2 days
**Impact:** Reusable for all future schema changes
### Future: Add Migrations as Needed
**For each schema change:**
1. Write migration function
2. Add to migrations array
3. Test thoroughly
4. Deploy with automatic migration
---
## Code Structure
### File Organization
```
src/
├── migrations.c # NEW: Migration system
├── migrations.h # NEW: Migration API
├── sql_schema.h # Schema definition (v11)
├── main.c # Updated store_event() and handle_req_message()
└── ...
```
### Migration API
```c
// migrations.h
int init_migration_system(sqlite3* db);
int run_pending_migrations(sqlite3* db);
int backup_database(const char* db_path, char* backup_path, size_t backup_path_size);
int rollback_migration(sqlite3* db, const char* backup_path);
```
---
## Testing Strategy
### For Fresh Start (v11)
1. **Local testing:**
- Build with new schema
- Post test events
- Query events and measure performance
- Verify event_json is populated correctly
2. **Performance testing:**
- Query 366 events
- Measure time (should be <10ms instead of 18s)
- Check CPU usage (should be <20%)
3. **Production deployment:**
- Stop relay
- Delete old database
- Start relay with v11 schema
- Monitor performance
### For Migration System (Future)
1. **Unit tests:**
- Test each migration function
- Test rollback capability
- Test error handling
2. **Integration tests:**
- Create database with old schema
- Run migration
- Verify data integrity
- Test rollback
3. **Performance tests:**
- Measure migration time for large databases
- Test with 10K, 100K, 1M events
- Optimize slow migrations
---
## Migration Complexity Analysis
### For v10 → v11 Migration
**If we were to migrate existing data:**
```sql
-- Step 1: Add column (fast)
ALTER TABLE events ADD COLUMN event_json TEXT;
-- Step 2: Populate event_json (SLOW!)
-- For each of N events:
-- 1. SELECT 7 columns
-- 2. Reconstruct JSON (cJSON operations)
-- 3. Serialize to string (cJSON_Print)
-- 4. UPDATE event_json column
-- 5. Free memory
-- Estimated time:
-- - 1000 events: ~10 seconds
-- - 10000 events: ~100 seconds
-- - 100000 events: ~1000 seconds (16 minutes)
```
**Conclusion:** Migration is expensive for this change. Fresh start is better.
---
## Future Migration Examples
### Easy Migrations (Fast)
**Adding an index:**
```c
int migrate_add_index(sqlite3* db) {
return sqlite3_exec(db,
"CREATE INDEX idx_new ON events(new_column)",
NULL, NULL, NULL);
}
```
**Adding a column with default:**
```c
int migrate_add_column(sqlite3* db) {
return sqlite3_exec(db,
"ALTER TABLE events ADD COLUMN new_col TEXT DEFAULT ''",
NULL, NULL, NULL);
}
```
### Hard Migrations (Slow)
**Changing column type:**
- Requires table recreation
- Copy all data
- Recreate indexes
- Can take minutes for large databases
**Populating computed columns:**
- Requires row-by-row processing
- Can take minutes for large databases
---
## Recommendation Summary
### For This Change (event_json)
**Do:** Fresh start with v11 schema
- Fast deployment
- Clean implementation
- Immediate performance benefit
- No migration complexity
**Don't:** Migrate existing data
- Too slow (reconstruct JSON for all events)
- Too complex (first migration)
- Not worth it (relay still in development)
### For Future Changes
**Do:** Implement migration system
- Professional approach
- Data preservation
- Reusable framework
- Required for production relay
**Timeline:**
- **Today:** Deploy v11 with fresh start
- **This week:** Build migration framework
- **Future:** Use migrations for all schema changes
---
## Next Steps
1. ✅ Update schema to v11 (add event_json column)
2. ✅ Update store_event() to populate event_json
3. ✅ Update handle_req_message() to use event_json
4. ✅ Test locally with 366-event query
5. ✅ Deploy to production with fresh database
6. ✅ Measure performance improvement
7. ⏳ Build migration system for future use
**Expected result:** 366-event retrieval time drops from 18s to <10ms (2,500x speedup)
+312
View File
@@ -0,0 +1,312 @@
# Event Tags Denormalization Plan
## Problem Statement
The relay is at 99% CPU with 1,178 WebSocket connections and ~120 new REQ subscriptions per minute. The root cause is that every tag-filtered REQ query (e.g., `#g`, `#e`, `#p`) uses `json_each(json(tags))` with `json_extract()` — a correlated subquery that parses the JSON tags column for every candidate row. With geohash-based subscriptions from a location app generating constant connection churn, this creates unsustainable CPU load.
### Evidence
- `EXPLAIN QUERY PLAN` shows: `CORRELATED SCALAR SUBQUERY → SCAN json_each VIRTUAL TABLE`
- 8,077 subscription creates/hour but only 442 active at any time (connection churn)
- Only 56 events stored/hour — the load is entirely from **read queries**, not writes
- 14,819 events match the kind filter; each query parses JSON tags on candidate rows
## Solution: Denormalized `event_tags` Table
Replace `json_each()` queries with indexed lookups on a separate `event_tags` table. This is the standard approach used by strfry, nostream, and other production Nostr relays.
### Performance Impact
| Metric | Before | After |
|--------|--------|-------|
| Tag query type | `json_each()` correlated subquery | Indexed JOIN/subquery |
| Complexity per query | O(rows × tags_per_event) JSON parsing | O(log n) B-tree lookup |
| Expected CPU reduction | 99% | <10% for same traffic |
## Files to Modify
| File | Changes |
|------|---------|
| `src/sql_schema.h` | Add `event_tags` table, indexes, cascade triggers; bump to schema v12 |
| `src/main.c` | Add `store_event_tags()`, update `handle_req_message()` tag filter SQL, move config reads out of row loop, add startup tag population |
| `src/nip009.c` | Add `DELETE FROM event_tags` alongside event deletions |
| `src/dm_admin.c` | Downgrade NIP-17 decryption failure log level |
| `src/websockets.c` | Downgrade NIP-17 error log to DEBUG at lines 780 and 1488 |
## Detailed Changes
### 1. Schema: `src/sql_schema.h`
Add the `event_tags` table after the events table definition. Bump schema version to 12.
```sql
-- Denormalized event tags for fast indexed lookups
-- Replaces json_each(json(tags)) queries which cause full JSON parsing per row
CREATE TABLE event_tags (
event_id TEXT NOT NULL,
tag_name TEXT NOT NULL,
tag_value TEXT NOT NULL,
tag_index INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
);
-- Primary lookup index: find events by tag name + value
CREATE INDEX idx_event_tags_lookup ON event_tags(tag_name, tag_value);
-- Reverse lookup: find all tags for an event (for cleanup)
CREATE INDEX idx_event_tags_event ON event_tags(event_id);
-- Composite index for common query pattern: tag + kind (via join)
CREATE INDEX idx_event_tags_value_name ON event_tags(tag_value, tag_name);
```
**Key design decisions:**
- `ON DELETE CASCADE` handles cleanup when events are deleted (NIP-09, replaceable events)
- `tag_index` stores the position within the tags array (useful for ordered tag access)
- Only the first two elements of each tag are indexed (`tag_name` = `$[0]`, `tag_value` = `$[1]`) — this covers all standard Nostr tag filters (`#e`, `#p`, `#t`, `#g`, `#d`, etc.)
- `PRAGMA foreign_keys = ON` is already in the schema, so CASCADE will work
### 2. Store Tags: `src/main.c` — New `store_event_tags()` Function
Add a new function called after successful event INSERT in `store_event()`:
```c
// Insert denormalized tags into event_tags table for fast indexed lookups
int store_event_tags(const char* event_id, cJSON* tags) {
if (!g_db || !event_id || !tags || !cJSON_IsArray(tags)) {
return 0; // Not an error if no tags
}
const char* sql = "INSERT INTO event_tags (event_id, tag_name, tag_value, tag_index) VALUES (?, ?, ?, ?)";
sqlite3_stmt* stmt;
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("Failed to prepare event_tags insert: %s", sqlite3_errmsg(g_db));
return -1;
}
int tag_index = 0;
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, tags) {
if (cJSON_IsArray(tag) && cJSON_GetArraySize(tag) >= 2) {
cJSON* name = cJSON_GetArrayItem(tag, 0);
cJSON* value = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(name) && cJSON_IsString(value)) {
sqlite3_reset(stmt);
sqlite3_bind_text(stmt, 1, event_id, -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 2, cJSON_GetStringValue(name), -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 3, cJSON_GetStringValue(value), -1, SQLITE_STATIC);
sqlite3_bind_int(stmt, 4, tag_index);
rc = sqlite3_step(stmt);
if (rc != SQLITE_DONE) {
DEBUG_ERROR("Failed to insert event tag: %s", sqlite3_errmsg(g_db));
}
}
}
tag_index++;
}
sqlite3_finalize(stmt);
return 0;
}
```
**Call site** — in `store_event()` at line ~784, after `monitoring_on_event_stored()`:
```c
// After successful event storage, insert denormalized tags
store_event_tags(cJSON_GetStringValue(id), tags);
```
### 3. Update REQ Query Builder: `src/main.c` — `handle_req_message()`
Replace the `json_each()` tag filter at lines 1243-1270 with an `event_tags` subquery:
**Before** (current code at line 1244):
```c
snprintf(sql_ptr, remaining,
" AND EXISTS (SELECT 1 FROM json_each(json(tags)) "
"WHERE json_extract(value, '$[0]') = ? "
"AND json_extract(value, '$[1]') IN (");
```
**After** (new code):
```c
snprintf(sql_ptr, remaining,
" AND id IN (SELECT event_id FROM event_tags "
"WHERE tag_name = ? AND tag_value IN (");
```
The rest of the parameter binding code (lines 1248-1270) stays the same — the bind params are identical (`tag_name` then `tag_value(s)`). Only the SQL template changes.
The closing parenthesis changes from `"))` to `"))` — same syntax, just different semantics.
### 4. NIP-09 Delete Cascade: `src/nip009.c`
The `ON DELETE CASCADE` foreign key handles this automatically. When `DELETE FROM events WHERE id = ?` executes, SQLite will automatically delete matching rows from `event_tags`. **No code changes needed in nip009.c** as long as `PRAGMA foreign_keys = ON` is set (it already is in the schema).
However, verify the replaceable/addressable event triggers in the schema also cascade properly. The existing triggers at schema lines 97-119 use `DELETE FROM events WHERE ...` which will trigger the CASCADE.
### 5. Performance Fix: Move Config Reads Out of Row Loop
In `handle_req_message()` at lines 1400-1401, `get_config_bool()` is called **inside the `while (sqlite3_step())` loop** — meaning it executes a SQLite query for every row returned. Move these before the loop:
**Before** (inside loop):
```c
while (sqlite3_step(stmt) == SQLITE_ROW) {
// ...
int expiration_enabled = get_config_bool("expiration_enabled", 1); // SQLite query per row!
int filter_responses = get_config_bool("expiration_filter", 1); // SQLite query per row!
```
**After** (before loop):
```c
int expiration_enabled = get_config_bool("expiration_enabled", 1);
int filter_responses = get_config_bool("expiration_filter", 1);
while (sqlite3_step(stmt) == SQLITE_ROW) {
// ... use cached values
```
### 6. Log Level Fixes
#### NIP-17 Decryption Failure — `src/websockets.c` lines 780 and 1488
Change from `DEBUG_ERROR` to `DEBUG_INFO`:
```c
// Before:
DEBUG_ERROR("NIP-17 admin message processing failed");
// After:
DEBUG_INFO("NIP-17 admin message processing failed");
```
This is expected behavior when non-admin gift wraps arrive — not an error condition.
#### Duplicate Event INSERT — `src/main.c` line 741
The `DEBUG_ERROR` at line 741 fires before the CONSTRAINT check at line 746. Suppress it for constraint violations:
```c
// Before (line 738-741):
if (rc != SQLITE_DONE) {
const char* err_msg = sqlite3_errmsg(g_db);
int extended_errcode = sqlite3_extended_errcode(g_db);
DEBUG_ERROR("INSERT failed: rc=%d, extended_errcode=%d, msg=%s", rc, extended_errcode, err_msg);
}
// After:
if (rc != SQLITE_DONE) {
const char* err_msg = sqlite3_errmsg(g_db);
int extended_errcode = sqlite3_extended_errcode(g_db);
if (rc != SQLITE_CONSTRAINT) {
DEBUG_ERROR("INSERT failed: rc=%d, extended_errcode=%d, msg=%s", rc, extended_errcode, err_msg);
}
}
```
### 7. Startup Tag Population
Since you said no migration code is needed (fresh deploy), the `event_tags` table will start empty and populate as new events arrive. Existing events won't have tags in the lookup table.
However, to avoid a period where old events don't appear in tag-filtered queries, add a one-time population function that runs at startup:
```c
// Populate event_tags from existing events (run once at startup)
int populate_event_tags_from_existing(void) {
if (!g_db) return -1;
// Check if event_tags is already populated
sqlite3_stmt* check_stmt;
sqlite3_prepare_v2(g_db, "SELECT COUNT(*) FROM event_tags", -1, &check_stmt, NULL);
if (sqlite3_step(check_stmt) == SQLITE_ROW && sqlite3_column_int(check_stmt, 0) > 0) {
sqlite3_finalize(check_stmt);
DEBUG_INFO("event_tags already populated, skipping");
return 0;
}
sqlite3_finalize(check_stmt);
DEBUG_INFO("Populating event_tags from existing events...");
const char* sql = "SELECT id, tags FROM events WHERE tags != '[]'";
sqlite3_stmt* stmt;
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) return -1;
// Use a transaction for bulk insert performance
sqlite3_exec(g_db, "BEGIN TRANSACTION", NULL, NULL, NULL);
int event_count = 0;
while (sqlite3_step(stmt) == SQLITE_ROW) {
const char* event_id = (const char*)sqlite3_column_text(stmt, 0);
const char* tags_json = (const char*)sqlite3_column_text(stmt, 1);
if (event_id && tags_json) {
cJSON* tags = cJSON_Parse(tags_json);
if (tags) {
store_event_tags(event_id, tags);
cJSON_Delete(tags);
event_count++;
}
}
}
sqlite3_finalize(stmt);
sqlite3_exec(g_db, "COMMIT", NULL, NULL, NULL);
DEBUG_INFO("Populated event_tags for %d events", event_count);
return 0;
}
```
Call this during startup, after database initialization but before accepting connections.
## Architecture Diagram
```mermaid
flowchart TD
subgraph "Current: O(n) per query"
A1["REQ with #g filter"] --> B1["SQL: SELECT ... WHERE EXISTS"]
B1 --> C1["json_each(json(tags))"]
C1 --> D1["json_extract per row"]
D1 --> E1["🔴 Full JSON parse per candidate row"]
end
subgraph "New: O(log n) per query"
A2["REQ with #g filter"] --> B2["SQL: SELECT ... WHERE id IN"]
B2 --> C2["event_tags table"]
C2 --> D2["idx_event_tags_lookup"]
D2 --> E2["🟢 B-tree index lookup"]
end
subgraph "Write Path (unchanged speed)"
F["EVENT arrives"] --> G["store_event()"]
G --> H["INSERT INTO events"]
H --> I["store_event_tags()"]
I --> J["INSERT INTO event_tags\n(one row per tag)"]
end
subgraph "Delete Path (automatic)"
K["NIP-09 DELETE"] --> L["DELETE FROM events"]
L --> M["ON DELETE CASCADE"]
M --> N["event_tags rows auto-deleted"]
end
```
## Testing Strategy
1. Run existing test suite: `tests/run_all_tests.sh`
2. Specifically run NIP tests that use tag filters: `tests/run_nip_tests.sh`
3. Verify tag-filtered REQ queries return correct results
4. Verify NIP-09 deletion cascades to event_tags
5. Verify replaceable event triggers cascade to event_tags
6. Monitor CPU usage after deployment with same traffic pattern
## Risk Assessment
- **Low risk**: The `event_tags` table is additive — it doesn't change the events table structure
- **Low risk**: `ON DELETE CASCADE` is a well-tested SQLite feature
- **Low risk**: The SQL change in `handle_req_message()` is a drop-in replacement (same bind params)
- **Medium risk**: Startup population on a large database could take a few seconds — but with only 55K events, this should complete in under 1 second
- **Write overhead**: Each event INSERT now also inserts ~5 rows into event_tags — negligible at 56 events/hour
+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.
+298
View File
@@ -0,0 +1,298 @@
# Web of Trust (WoT) Implementation Plan
## Feature Description
When enabled, the relay restricts access to pubkeys that the admin follows. The admin's kind 3 (contact list) event is used as the source of truth — all `p` tags in that event become whitelisted pubkeys. The admin themselves is always whitelisted.
Single config variable `wot_enabled` with three levels:
| Value | Mode | Effect |
|-------|------|--------|
| `0` | Off | Open relay — anyone can read and write |
| `1` | Write-only | Only followed pubkeys can **publish** events. Anyone can read/subscribe. |
| `2` | Full | Only followed pubkeys can **publish AND subscribe**. Requires NIP-42 auth for subscriptions. Eliminates subscription churn from anonymous connections. |
## How It Works
### WoT Sync Flow
```mermaid
flowchart TD
A["Admin publishes kind 3 event\n- contact list -"] --> B{wot_enabled > 0?}
B -->|No| C["Store event normally"]
B -->|Yes| D["Extract p tags from kind 3"]
D --> E["Clear existing WoT whitelist rules"]
E --> F["Insert new whitelist rules\nfor each followed pubkey"]
F --> G["Enable auth_enabled"]
G --> H{wot_enabled == 2?}
H -->|Yes| I["Enable nip42_auth_required_subscriptions"]
H -->|No| J["Done - write-only restriction"]
```
### Event Publishing Flow — Write Restriction (wot_enabled >= 1)
```mermaid
flowchart TD
A["EVENT message arrives"] --> B{auth_enabled?}
B -->|No| C["Accept event"]
B -->|Yes| D["check_database_auth_rules - pubkey -"]
D --> E{Pubkey in whitelist\nor wot_whitelist?}
E -->|Yes| C
E -->|No| F{Any whitelist rules exist?}
F -->|Yes| G["REJECT: not whitelisted"]
F -->|No| C
```
### Subscription Flow — Read Restriction (wot_enabled == 2)
```mermaid
flowchart TD
A["REQ message arrives"] --> B{wot_enabled == 2?}
B -->|No| C["Allow subscription"]
B -->|Yes| D{NIP-42 authenticated?}
D -->|No| E["Send AUTH challenge"]
D -->|Yes| F["check_database_auth_rules\nusing authenticated_pubkey"]
F --> G{Pubkey in whitelist\nor wot_whitelist?}
G -->|Yes| C
G -->|No| H["REJECT: not authorized\nfor subscriptions"]
```
## Design Decisions
### Leveraging Existing Infrastructure
The relay already has everything needed:
1. **`auth_rules` table** — stores whitelist/blacklist rules with `rule_type`, `pattern_type`, `pattern_value`
2. **`check_database_auth_rules()`** in [`request_validator.c:529`](src/request_validator.c:529) — already implements the logic: "if whitelist rules exist and pubkey is not whitelisted, deny"
3. **`add_auth_rule_from_config()`** / **`remove_auth_rule_from_config()`** in [`config.c`](src/config.c:2082) — already manage auth rules
4. **`event_tags` table** — can efficiently query `p` tags from kind 3 events
5. **Config system**`auth_enabled` flag already controls whether auth rules are checked
6. **NIP-42 auth**`nip42_auth_required_subscriptions` already gates REQ access, `pss->authenticated_pubkey` stores the authenticated pubkey
### WoT-Specific Whitelist Rules
To distinguish WoT-generated whitelist rules from manually-added ones, we use a new `rule_type` value: `wot_whitelist`. This allows:
- Clearing all WoT rules without affecting manual whitelist/blacklist rules
- Querying WoT status separately
- The existing `check_database_auth_rules()` function already checks for `rule_type = 'whitelist'` — we need to also match `wot_whitelist` in the whitelist check
### Trigger Mechanism
The WoT sync happens when:
1. **A kind 3 event from the admin is stored** — detected in `store_event()` after successful INSERT
2. **Startup** — if `wot_enabled > 0`, sync from the most recent admin kind 3 event in the database
3. **Admin DM command**`wot sync` to force a manual resync
### What Gets Whitelisted
- All pubkeys in `p` tags of the admin's kind 3 event
- The admin pubkey itself (always whitelisted)
- The relay pubkey (always whitelisted — for admin DM responses)
### What Is NOT Blocked
Even with WoT enabled, these are always allowed:
- Admin events (kind 23456) — already bypassed in [`request_validator.c:303`](src/request_validator.c:303)
- NIP-42 auth events (kind 22242) — already bypassed in [`request_validator.c:318`](src/request_validator.c:318)
- Kind 3 events from the admin — needed to update the follow list itself
- Kind 1059 gift wraps addressed to the relay — needed for admin DMs
## Files to Modify
| File | Changes |
|------|---------|
| `src/default_config_event.h` | Add `wot_enabled` config key (default: `0`) |
| `src/config.c` | Add `wot_sync_from_admin_kind3()` function |
| `src/main.c` | Add WoT trigger in `store_event()` when admin kind 3 is stored, add startup WoT sync |
| `src/request_validator.c` | Update whitelist SQL to also match `wot_whitelist` rule type |
| `src/sql_schema.h` | Update `auth_rules` CHECK constraint to include `wot_whitelist` |
| `src/websockets.c` | Add WoT pubkey check after NIP-42 auth check in REQ handler |
| `src/dm_admin.c` | Add `wot 0`, `wot 1`, `wot 2`, `wot sync`, `wot status` DM commands |
## Detailed Changes
### 1. Schema: `src/sql_schema.h`
Update the `auth_rules` table CHECK constraint to allow `wot_whitelist`:
```sql
-- Before:
rule_type TEXT NOT NULL CHECK (rule_type IN ('whitelist', 'blacklist', 'rate_limit', 'auth_required'))
-- After:
rule_type TEXT NOT NULL CHECK (rule_type IN ('whitelist', 'blacklist', 'rate_limit', 'auth_required', 'wot_whitelist'))
```
### 2. Config: `src/default_config_event.h`
Add WoT configuration:
```c
// Web of Trust Settings
// 0 = off, 1 = write-only (followed pubkeys can publish), 2 = full (followed pubkeys can publish AND subscribe)
{"wot_enabled", "0"},
```
### 3. Core WoT Sync Function: `src/config.c`
New function `wot_sync_from_admin_kind3()`:
```c
int wot_sync_from_admin_kind3(void) {
int wot_level = get_config_int("wot_enabled", 0);
if (wot_level <= 0) return 0; // WoT disabled
// 1. Get admin pubkey from config
// 2. Query event_tags for p tags from admin's latest kind 3 event:
// SELECT DISTINCT et.tag_value FROM event_tags et
// JOIN events e ON et.event_id = e.id
// WHERE e.kind = 3 AND e.pubkey = ? AND et.tag_name = 'p'
// ORDER BY e.created_at DESC
// 3. BEGIN TRANSACTION
// 4. DELETE FROM auth_rules WHERE rule_type = 'wot_whitelist'
// 5. INSERT wot_whitelist for admin pubkey
// 6. INSERT wot_whitelist for relay pubkey
// 7. For each p tag: INSERT INTO auth_rules (rule_type, pattern_type, pattern_value)
// VALUES ('wot_whitelist', 'pubkey', ?)
// 8. COMMIT
// 9. Set auth_enabled = true
// 10. If wot_level == 2: set nip42_auth_required_subscriptions = true
// 11. Log count of whitelisted pubkeys
return 0;
}
```
### 4. Trigger on Kind 3 Store: `src/main.c`
In `store_event()`, after successful INSERT and after `store_event_tags()`:
```c
// Check if this is a kind 3 event from the admin — trigger WoT sync
if ((int)cJSON_GetNumberValue(kind) == 3) {
int wot_level = get_config_int("wot_enabled", 0);
if (wot_level > 0) {
const char* admin_pubkey = get_config_value("admin_pubkey");
if (admin_pubkey && strcmp(cJSON_GetStringValue(pubkey), admin_pubkey) == 0) {
DEBUG_INFO("Admin kind 3 event stored — triggering WoT sync");
wot_sync_from_admin_kind3();
}
if (admin_pubkey) free((char*)admin_pubkey);
}
}
```
### 5. Startup WoT Sync: `src/main.c`
In `main()`, after `populate_event_tags_from_existing()`:
```c
// Sync Web of Trust whitelist if enabled
int wot_level = get_config_int("wot_enabled", 0);
if (wot_level > 0) {
wot_sync_from_admin_kind3();
}
```
### 6. Update Whitelist Check: `src/request_validator.c`
Update the whitelist SQL queries to also match `wot_whitelist`:
```c
// Before:
"SELECT rule_type FROM auth_rules WHERE rule_type = 'whitelist' AND pattern_type = 'pubkey' AND pattern_value = ? AND active = 1 LIMIT 1"
// After:
"SELECT rule_type FROM auth_rules WHERE rule_type IN ('whitelist', 'wot_whitelist') AND pattern_type = 'pubkey' AND pattern_value = ? AND active = 1 LIMIT 1"
```
And the whitelist-exists check:
```c
// Before:
"SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'whitelist' AND pattern_type = 'pubkey' AND active = 1 LIMIT 1"
// After:
"SELECT COUNT(*) FROM auth_rules WHERE rule_type IN ('whitelist', 'wot_whitelist') AND pattern_type = 'pubkey' AND active = 1 LIMIT 1"
```
### 7. REQ Handler WoT Check: `src/websockets.c`
When `wot_enabled == 2`, add a pubkey whitelist check **after** the existing NIP-42 auth check in the REQ handler. The existing code at line 903 already requires NIP-42 auth when `nip42_auth_required_subscriptions` is set. We add a WoT check right after:
```c
// Existing NIP-42 auth check (line 903):
if (pss && pss->nip42_auth_required_subscriptions && !pss->authenticated) {
// ... send AUTH challenge or NOTICE ...
return 0;
}
// NEW: WoT read restriction check (wot_enabled == 2)
if (pss && pss->authenticated && get_config_int("wot_enabled", 0) == 2) {
// Client is authenticated — check if their pubkey is in the WoT whitelist
int wot_result = check_database_auth_rules(pss->authenticated_pubkey, "subscription", NULL);
if (wot_result != NOSTR_SUCCESS) {
send_notice_message(wsi, pss, "restricted: your pubkey is not in this relay's web of trust");
DEBUG_INFO("REQ rejected: pubkey %s not in WoT whitelist", pss->authenticated_pubkey);
cJSON_Delete(json);
return 0;
}
}
```
### 8. Admin DM Commands: `src/dm_admin.c`
Add plain text DM commands:
| Command | Action |
|---------|--------|
| `wot 0` or `wot off` | Disable WoT, delete all `wot_whitelist` rules, reset `nip42_auth_required_subscriptions` |
| `wot 1` or `wot write` | Write-only WoT — sync follow list, set `auth_enabled=true` |
| `wot 2` or `wot full` | Full WoT — sync follow list, set `auth_enabled=true`, set `nip42_auth_required_subscriptions=true` |
| `wot sync` | Force resync from admin's kind 3 event |
| `wot status` | Show WoT level (0/1/2), count of whitelisted pubkeys |
### 9. NIP-11 Update
When WoT is enabled (level 1 or 2), the relay should indicate this in NIP-11 relay info. Add to the `limitation` object:
```json
"auth_required": true
```
## Edge Cases
1. **Admin has no kind 3 event in database**: WoT sync does nothing, logs a warning. No whitelist rules created = open relay.
2. **Admin updates follow list**: New kind 3 event triggers full resync — old WoT rules are cleared, new ones inserted. This is atomic (transaction).
3. **WoT disabled (set to 0)**: Clears all `wot_whitelist` rules. Manual `whitelist` rules are preserved. `nip42_auth_required_subscriptions` is reset to false.
4. **Admin unfollows someone**: Next kind 3 event triggers resync, the unfollowed pubkey's `wot_whitelist` rule is removed (full clear + reinsert).
5. **Kind 1059 gift wraps**: These need special handling — the relay needs to accept gift wraps addressed to it even from non-whitelisted pubkeys (for admin DMs). The `is_nip17_gift_wrap_for_relay()` check should bypass WoT.
6. **Read restriction without NIP-42 support (level 2)**: If a client doesn't support NIP-42, they cannot authenticate and therefore cannot subscribe. This is by design — it's the most effective way to reduce subscription churn from anonymous connections.
7. **NIP-11 discovery**: Clients can check NIP-11 to see if `auth_required` is true before connecting, avoiding wasted connections.
8. **Changing level from 2 to 1**: `nip42_auth_required_subscriptions` is reset to false, allowing anonymous subscriptions again. WoT whitelist rules remain for write restriction.
## Testing Strategy
1. Enable write-only WoT via DM: `wot 1`
2. Verify admin can still publish events
3. Verify followed pubkeys can publish events
4. Verify non-followed pubkeys are rejected for EVENT
5. Verify non-followed pubkeys can still subscribe (REQ)
6. Enable full WoT via DM: `wot 2`
7. Verify unauthenticated clients get AUTH challenge on REQ
8. Verify authenticated non-followed pubkeys are rejected for REQ
9. Verify authenticated followed pubkeys can subscribe
10. Verify `wot status` shows correct level and count
11. Publish new kind 3 event, verify auto-resync
12. `wot 0` — verify all pubkeys can publish and subscribe again
13. Verify admin DMs still work when WoT is enabled
## Performance Considerations
- WoT sync is O(n) where n = number of follows (typically 100-2000)
- Uses a transaction for bulk insert — fast even for large follow lists
- Auth rule check is already indexed: `idx_auth_rules_pattern ON auth_rules(pattern_type, pattern_value)`
- No additional per-event overhead — the existing `check_database_auth_rules()` already runs on every event when auth is enabled
- **Level 2 directly addresses CPU load**: With `wot_enabled=2`, unauthenticated connections cannot create subscriptions, eliminating the ~120 REQ/minute churn from anonymous connections that was causing 99% CPU
+388
View File
@@ -0,0 +1,388 @@
# Web of Trust — Admin Web UI Plan
## Overview
Add a **Web of Trust** section to the existing Authorization page in the admin web interface. The section lets the admin see whether their kind 3 contact list is on the relay, select a WoT level, trigger a sync, and view the current whitelist count.
## Architecture
### Data Flow
```mermaid
flowchart LR
A[Web UI] -->|config_set wot_enabled N| B[Kind 23456 Event]
A -->|system_command wot_sync| B
A -->|system_command wot_status| B
B --> C[Relay Backend]
C -->|Kind 23457 Response| D[Web UI updates display]
```
### How It Works
1. **On page load** — the Authorization page already calls `loadAuthRules`. We add a parallel call to `loadWotStatus` which sends a `system_command` / `wot_status` event.
2. **Backend responds** with a kind 23457 JSON containing: `wot_enabled` level, `wot_whitelist_count`, and `admin_kind3_exists` boolean.
3. **UI renders** a card showing:
- Whether the admin's kind 3 event exists on the relay
- Current WoT level as a 3-option radio/button group
- Count of whitelisted pubkeys
- A SYNC button to force resync
4. **Changing level** sends `config_set` / `wot_enabled` / `0|1|2` via the existing admin API, then triggers `wot_sync` if level > 0.
5. **SYNC button** sends `system_command` / `wot_sync`.
## Detailed Changes
### 1. Backend: New system commands in `src/config.c`
Add two new branches to `handle_system_command_unified`:
#### `wot_status` command
Returns JSON response:
```json
{
"command": "wot_status",
"status": "success",
"wot_enabled": 2,
"admin_kind3_exists": true,
"wot_whitelist_count": 347,
"timestamp": 1234567890
}
```
Implementation:
- Read `wot_enabled` from config table
- Query `SELECT COUNT(*) FROM events WHERE kind = 3 AND pubkey = ?` with admin_pubkey to check kind 3 existence
- Query `SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'wot_whitelist' AND active = 1` for whitelist count
- Return as kind 23457 signed response
#### `wot_sync` command
Calls `wot_sync_from_admin_kind3` and returns result:
```json
{
"command": "wot_sync",
"status": "success",
"wot_whitelist_count": 347,
"timestamp": 1234567890
}
```
### 2. Backend: Hook `config_set` for `wot_enabled`
In `handle_config_set_unified`, after the config value is updated, add a check:
```c
// After successful update, trigger WoT sync if wot_enabled changed
if (strcmp(config_key, "wot_enabled") == 0) {
int new_level = atoi(config_value);
if (new_level > 0) {
wot_sync_from_admin_kind3();
} else {
// Level 0: clear wot_whitelist rules and reset auth flags
sqlite3_exec(g_db, "DELETE FROM auth_rules WHERE rule_type = 'wot_whitelist'", NULL, NULL, NULL);
update_config_in_table("nip42_auth_required_subscriptions", "false");
}
}
```
### 3. Frontend: HTML — WoT section in `api/index.html`
Add a new div **inside** the `authRulesSection`, before the auth rules table. This keeps WoT visually grouped with authorization:
```html
<!-- Web of Trust Section -->
<div id="wotSection" class="input-group">
<div class="section-header" style="font-size: 14px; margin-bottom: 10px;">
WEB OF TRUST
</div>
<!-- Kind 3 Status Indicator -->
<div id="wotKind3Status" class="wot-status-row">
<span>Admin Contact List (kind 3):</span>
<span id="wotKind3Indicator" class="wot-indicator wot-unknown">Checking...</span>
</div>
<!-- WoT Level Selector -->
<div class="wot-level-selector">
<label>WoT Level:</label>
<div class="inline-buttons">
<button type="button" id="wotLevel0Btn" class="wot-level-btn" onclick="setWotLevel(0)">
OFF
</button>
<button type="button" id="wotLevel1Btn" class="wot-level-btn" onclick="setWotLevel(1)">
WRITE ONLY
</button>
<button type="button" id="wotLevel2Btn" class="wot-level-btn" onclick="setWotLevel(2)">
FULL
</button>
</div>
<div class="wot-level-description" id="wotLevelDescription">
Level 0: Open relay — anyone can read and write
</div>
</div>
<!-- WoT Stats -->
<div class="wot-stats-row">
<span>Whitelisted Pubkeys:</span>
<span id="wotWhitelistCount"></span>
</div>
<!-- Sync Button -->
<div class="inline-buttons">
<button type="button" id="wotSyncBtn" onclick="syncWot()">SYNC FROM KIND 3</button>
<button type="button" id="wotRefreshBtn" onclick="loadWotStatus()">REFRESH STATUS</button>
</div>
</div>
<hr style="margin: 15px 0; border-color: var(--border-color);">
```
### 4. Frontend: CSS additions in `api/index.css`
```css
/* Web of Trust styles */
.wot-status-row, .wot-stats-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
font-size: 14px;
}
.wot-indicator {
padding: 2px 10px;
border-radius: 4px;
font-weight: bold;
font-size: 12px;
}
.wot-indicator.wot-found { background: #28a745; color: white; }
.wot-indicator.wot-missing { background: #dc3545; color: white; }
.wot-indicator.wot-unknown { background: #6c757d; color: white; }
.wot-level-selector { padding: 10px 0; }
.wot-level-selector label { display: block; margin-bottom: 5px; font-weight: bold; }
.wot-level-btn { min-width: 100px; }
.wot-level-btn.active {
background: var(--accent-color, #007bff);
color: white;
border-color: var(--accent-color, #007bff);
}
.wot-level-description {
font-size: 12px;
color: var(--text-secondary);
margin-top: 5px;
font-style: italic;
}
```
### 5. Frontend: JavaScript functions in `api/index.js`
#### `loadWotStatus` — query current WoT state
```javascript
async function loadWotStatus() {
try {
if (!isLoggedIn || !userPubkey || !relayPool) return;
const command_array = ["system_command", "wot_status"];
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) return;
const event = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
const signedEvent = await window.nostr.signEvent(event);
const url = relayConnectionUrl.value.trim();
await relayPool.publish([url], signedEvent);
log('WoT status query sent', 'INFO');
} catch (error) {
log('Failed to load WoT status: ' + error.message, 'ERROR');
}
}
```
#### `setWotLevel` — change WoT level via config_set
```javascript
async function setWotLevel(level) {
try {
if (!isLoggedIn || !userPubkey || !relayPool) return;
// Send config_set for wot_enabled
const command_array = ["config_set", "wot_enabled", String(level)];
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) return;
const event = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
const signedEvent = await window.nostr.signEvent(event);
const url = relayConnectionUrl.value.trim();
await relayPool.publish([url], signedEvent);
log('WoT level set to ' + level, 'INFO');
// Refresh status after a short delay
setTimeout(() => loadWotStatus(), 1500);
} catch (error) {
log('Failed to set WoT level: ' + error.message, 'ERROR');
}
}
```
#### `syncWot` — force WoT sync
```javascript
async function syncWot() {
try {
if (!isLoggedIn || !userPubkey || !relayPool) return;
const command_array = ["system_command", "wot_sync"];
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) return;
const event = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
const signedEvent = await window.nostr.signEvent(event);
const url = relayConnectionUrl.value.trim();
await relayPool.publish([url], signedEvent);
log('WoT sync command sent', 'INFO');
// Refresh status after sync completes
setTimeout(() => loadWotStatus(), 2000);
} catch (error) {
log('Failed to sync WoT: ' + error.message, 'ERROR');
}
}
```
#### `handleWotStatusResponse` — process backend response
```javascript
function handleWotStatusResponse(responseData) {
const kind3Indicator = document.getElementById('wotKind3Indicator');
const whitelistCount = document.getElementById('wotWhitelistCount');
const levelDesc = document.getElementById('wotLevelDescription');
// Update kind 3 indicator
if (kind3Indicator) {
if (responseData.admin_kind3_exists) {
kind3Indicator.textContent = 'Found ✓';
kind3Indicator.className = 'wot-indicator wot-found';
} else {
kind3Indicator.textContent = 'Not Found ✗';
kind3Indicator.className = 'wot-indicator wot-missing';
}
}
// Update whitelist count
if (whitelistCount) {
whitelistCount.textContent = responseData.wot_whitelist_count || 0;
}
// Update level buttons
const level = responseData.wot_enabled || 0;
['wotLevel0Btn', 'wotLevel1Btn', 'wotLevel2Btn'].forEach((id, i) => {
const btn = document.getElementById(id);
if (btn) {
btn.classList.toggle('active', i === level);
}
});
// Update description
const descriptions = [
'Level 0: Open relay — anyone can read and write',
'Level 1: Write-only — only followed pubkeys can publish events',
'Level 2: Full — only followed pubkeys can publish AND subscribe (NIP-42 required)'
];
if (levelDesc) {
levelDesc.textContent = descriptions[level] || descriptions[0];
}
// Enable/disable sync button based on kind 3 existence
const syncBtn = document.getElementById('wotSyncBtn');
if (syncBtn) {
syncBtn.disabled = !responseData.admin_kind3_exists;
}
}
```
#### Wire into existing response handler
In the existing `handleSystemCommandResponse` function, add:
```javascript
if (responseData.command === 'wot_status' || responseData.command === 'wot_sync') {
handleWotStatusResponse(responseData);
}
```
#### Wire into page navigation
In the `authorization` case of the page switch handler, add:
```javascript
case 'authorization':
loadAuthRules().catch(...);
loadWotStatus().catch(error => {
console.log('Auto-load WoT status failed: ' + error.message);
});
break;
```
## Files to Modify
| File | Changes |
|------|---------|
| `src/config.c` | Add `wot_status` and `wot_sync` branches to `handle_system_command_unified`; add WoT sync hook to `handle_config_set_unified` |
| `api/index.html` | Add WoT section HTML inside `authRulesSection` |
| `api/index.css` | Add WoT-specific CSS styles |
| `api/index.js` | Add `loadWotStatus`, `setWotLevel`, `syncWot`, `handleWotStatusResponse` functions; wire into page nav and response handler |
## Edge Cases
1. **Admin not logged in** — WoT section shows but buttons are disabled; status shows "Checking..."
2. **No kind 3 event** — Indicator shows red "Not Found ✗"; SYNC button is disabled; level selector still works but sync will whitelist only admin + relay
3. **Level change from 2 to 0** — Backend clears all wot_whitelist rules and resets nip42_auth_required_subscriptions
4. **Concurrent config_set and wot_sync** — The backend handles these sequentially via SQLite transactions; no race condition
## UI Mockup
```
┌─────────────────────────────────────────────┐
│ WEB OF TRUST │
│ │
│ Admin Contact List (kind 3): [Found ✓] │
│ │
│ WoT Level: │
│ [ OFF ] [ WRITE ONLY ] [ FULL ] │
│ Level 2: Full — only followed pubkeys can │
│ publish AND subscribe (NIP-42 required) │
│ │
│ Whitelisted Pubkeys: 347 │
│ │
│ [ SYNC FROM KIND 3 ] [ REFRESH STATUS ] │
├─────────────────────────────────────────────┤
│ ─────────────────────────────────────────── │
│ AUTH RULES MANAGEMENT │
│ ... existing auth rules table ... │
└─────────────────────────────────────────────┘
```
+342
View File
@@ -0,0 +1,342 @@
# Database Query Performance Analysis Report
**Analysis Date:** 2026-02-02
**Log Duration:** ~6 minutes (15:24:50 - 15:30:58)
**Total Queries:** 366 queries
**Data Source:** serverlog.txt
---
## Executive Summary
The relay is experiencing moderate performance issues with an average query time of **10.4ms** and a maximum query time of **672ms**. The primary bottlenecks are:
1. **Tag-based searches using `json_each()`** - 53% of all queries (194/366)
2. **Monitoring system queries** - Taking 540-550ms each
3. **Multiple pubkey lookups** - Kind 10002 queries with 15-50 pubkeys
---
## Query Performance Metrics
### Overall Statistics
- **Total Queries:** 366
- **Average Query Time:** 10,440 μs (10.4 ms)
- **Minimum Query Time:** 14 μs
- **Maximum Query Time:** 672,846 μs (672.8 ms)
- **Slow Queries (>10ms):** 8 queries (2.2%)
### Query Type Breakdown
| Type | Count | Percentage |
|------|-------|------------|
| REQ | 359 | 98.1% |
| MONITOR | 7 | 1.9% |
---
## Critical Performance Issues
### 1. **SLOWEST QUERY: 672ms Tag Search (IP: 192.42.116.178)**
```sql
SELECT id, pubkey, created_at, kind, content, sig, tags
FROM events
WHERE 1=1
AND (kind < 20000 OR kind >= 30000)
AND kind IN (5,6300,7000,2004,1622)
AND EXISTS (
SELECT 1 FROM json_each(json(tags))
WHERE json_extract(value, '$[0]') = ?
AND json_extract(value, '$[1]') IN (?)
)
ORDER BY created_at DESC
LIMIT 100
```
**Problem:** Full table scan with JSON parsing for every row
**Impact:** 672ms for 0 results (wasted computation)
**Root Cause:** No index on tag values, requires scanning all events
---
### 2. **Monitoring System Queries: 540-550ms Each**
```sql
SELECT * FROM active_subscriptions_log
ORDER BY created_at DESC
```
**Occurrences:** 4 queries in 6 minutes
**Average Time:** 545ms
**Rows Returned:** 20-52 rows
**Problem:** Extremely slow for small result sets
**Root Cause:** Likely missing index on `created_at` column
---
### 3. **Tag-Based Searches (json_each) - 53% of All Queries**
- **Total:** 194 queries (53% of all queries)
- **Pattern:** `EXISTS (SELECT 1 FROM json_each(json(tags)) WHERE ...)`
- **Most Common:** Kind 1984 (105 queries), Kind 1111 (47 queries)
- **Problem:** Every tag search requires full JSON parsing
**Example Pattern:**
```sql
WHERE kind IN (1984)
AND pubkey IN (?)
AND EXISTS (
SELECT 1 FROM json_each(json(tags))
WHERE json_extract(value, '$[0]') = ?
AND json_extract(value, '$[1]') IN (?,?,?,?,?,?,?,?,?,?,?)
)
```
---
### 4. **Multiple Pubkey Lookups (Kind 10002)**
- **Total:** 64 queries for kind 10002
- **Average Time:** 2,500-3,300 μs (2.5-3.3ms)
- **Pattern:** Queries with 15-50 pubkeys in `IN` clause
- **Problem:** Large `IN` clauses without proper indexing
**Example:**
```sql
WHERE kind IN (10002)
AND pubkey IN (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
```
---
## Client Activity Analysis
### Top Query Sources (by IP)
| IP Address | Query Count | Percentage | Notes |
|------------|-------------|------------|-------|
| 45.84.107.222 | 101 | 27.6% | **Highest activity** |
| 23.234.109.54 | 69 | 18.9% | Second highest |
| 185.220.101.38 | 56 | 15.3% | Third highest |
| 192.42.116.178 | 51 | 13.9% | **Source of 672ms query** |
| 149.22.80.85 | 34 | 9.3% | |
| 174.138.53.241 | 24 | 6.6% | |
| Others | 31 | 8.5% | 6 other IPs |
**Observation:** Top 3 IPs account for 61.8% of all queries
---
## Most Common Query Patterns
| Kind Filter | Query Count | Percentage |
|-------------|-------------|------------|
| kind IN (1984) | 105 | 28.7% |
| kind IN (10002) | 64 | 17.5% |
| kind IN (1111) | 47 | 12.8% |
| kind IN (0,2,3,10002) | 24 | 6.6% |
| kind IN (9735) | 23 | 6.3% |
| kind IN (0,30315,10002,10050) | 20 | 5.5% |
| Others | 83 | 22.7% |
---
## Optimization Recommendations
### Priority 1: Critical (Immediate Action Required)
#### 1.1 Add Index on `active_subscriptions_log.created_at`
**Impact:** Will reduce monitoring queries from 540ms to <10ms
**Effort:** Low
**SQL:**
```sql
CREATE INDEX IF NOT EXISTS idx_active_subscriptions_created_at
ON active_subscriptions_log(created_at DESC);
```
#### 1.2 Implement Tag Indexing System
**Impact:** Will reduce tag searches from 100-600ms to <10ms
**Effort:** High
**Options:**
- **Option A:** Create separate `event_tags` table with indexes
```sql
CREATE TABLE event_tags (
event_id TEXT NOT NULL,
tag_name TEXT NOT NULL,
tag_value TEXT NOT NULL,
FOREIGN KEY (event_id) REFERENCES events(id)
);
CREATE INDEX idx_event_tags_lookup ON event_tags(tag_name, tag_value);
```
- **Option B:** Use SQLite JSON1 extension with generated columns (if available)
- **Option C:** Implement application-level tag caching
**Recommended:** Option A (most reliable and performant)
---
### Priority 2: High (Implement Within Week)
#### 2.1 Optimize Multiple Pubkey Queries
**Current:** `pubkey IN (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
**Problem:** Large IN clauses are inefficient
**Solution:**
- Add composite index: `CREATE INDEX idx_events_kind_pubkey ON events(kind, pubkey, created_at DESC);`
- Consider query rewriting for >10 pubkeys
#### 2.2 Add Query Result Caching
**Target Queries:**
- Kind 0 (profile) lookups - frequently repeated
- Kind 10002 (relay lists) - relatively static
- Kind 1984 (reports) - can be cached for 30-60 seconds
**Implementation:**
- Use in-memory LRU cache (e.g., 1000 entries)
- Cache key: hash of SQL + parameters
- TTL: 30-60 seconds for most queries
---
### Priority 3: Medium (Optimize Over Time)
#### 3.1 Disable or Throttle Monitoring Queries
**Current:** Monitoring queries run every ~60 seconds
**Impact:** Each query takes 540ms
**Options:**
- Increase throttle to 300 seconds (5 minutes)
- Disable monitoring entirely if not actively used
- Optimize `active_subscriptions_log` table structure
#### 3.2 Implement Query Complexity Limits
**Problem:** Some queries scan entire table (e.g., 672ms query returned 0 results)
**Solution:**
- Reject queries without time bounds (require `since` or `until`)
- Limit number of kinds in single query (max 10)
- Limit number of pubkeys in single query (max 20)
#### 3.3 Add Composite Indexes for Common Patterns
```sql
-- For kind + created_at queries (most common pattern)
CREATE INDEX idx_events_kind_created ON events(kind, created_at DESC);
-- For kind + pubkey + created_at queries
CREATE INDEX idx_events_kind_pubkey_created ON events(kind, pubkey, created_at DESC);
```
---
## Database Schema Recommendations
### Current Issues
1. **No tag indexing** - Forces full table scans with JSON parsing
2. **Missing created_at indexes** - Monitoring queries are extremely slow
3. **No composite indexes** - Multi-condition queries inefficient
### Recommended Schema Changes
```sql
-- 1. Add tag indexing table
CREATE TABLE IF NOT EXISTS event_tags (
event_id TEXT NOT NULL,
tag_name TEXT NOT NULL,
tag_value TEXT NOT NULL,
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
);
CREATE INDEX idx_event_tags_lookup ON event_tags(tag_name, tag_value);
CREATE INDEX idx_event_tags_event_id ON event_tags(event_id);
-- 2. Add monitoring table index
CREATE INDEX IF NOT EXISTS idx_active_subscriptions_created_at
ON active_subscriptions_log(created_at DESC);
-- 3. Add composite indexes for common query patterns
CREATE INDEX IF NOT EXISTS idx_events_kind_created
ON events(kind, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_kind_pubkey_created
ON events(kind, pubkey, created_at DESC);
-- 4. Add index for pubkey lookups
CREATE INDEX IF NOT EXISTS idx_events_pubkey_created
ON events(pubkey, created_at DESC);
```
---
## Expected Performance Improvements
| Optimization | Current Avg | Expected Avg | Improvement |
|--------------|-------------|--------------|-------------|
| Tag searches (with event_tags table) | 100-600ms | 5-20ms | **95-97%** |
| Monitoring queries (with index) | 540ms | 5-10ms | **98%** |
| Multiple pubkey queries (with index) | 2.5-3.3ms | 0.5-1ms | **70-80%** |
| Overall average query time | 10.4ms | 2-3ms | **70-80%** |
---
## Client Behavior Analysis
### Potentially Abusive Patterns
#### IP: 192.42.116.178 (51 queries)
- **Issue:** Generated the slowest query (672ms)
- **Pattern:** Complex tag searches with multiple kinds
- **Recommendation:** Monitor for repeated expensive queries
#### IP: 45.84.107.222 (101 queries - 27.6% of total)
- **Issue:** Highest query volume
- **Pattern:** Mix of kind 10002, 1984, and various other kinds
- **Recommendation:** Acceptable if queries are efficient; monitor for abuse
### Normal Behavior
- Most queries are <1ms (fast)
- Majority return 0-10 rows (reasonable)
- Query patterns match typical Nostr client behavior
---
## Action Plan
### Immediate (Today)
1. ✅ Add index on `active_subscriptions_log.created_at`
2. ✅ Increase monitoring throttle from 60s to 300s (or disable)
3. ✅ Monitor IP 192.42.116.178 for repeated expensive queries
### This Week
1. ⏳ Design and implement `event_tags` table
2. ⏳ Add composite indexes for common query patterns
3. ⏳ Implement query complexity limits (require time bounds)
### This Month
1. ⏳ Implement query result caching (LRU cache)
2. ⏳ Add query cost estimation and rejection
3. ⏳ Optimize subscription matching algorithm
---
## Monitoring Recommendations
### Key Metrics to Track
1. **Average query time** - Target: <5ms
2. **P95 query time** - Target: <50ms
3. **P99 query time** - Target: <100ms
4. **Queries >100ms** - Target: <1% of queries
5. **Tag search percentage** - Target: <30% after optimization
### Alert Thresholds
- **Critical:** Average query time >20ms for 5 minutes
- **Warning:** Any single query >1000ms (1 second)
- **Info:** Client making >100 queries/minute
---
## Conclusion
The relay is experiencing performance issues primarily due to:
1. **Lack of tag indexing** (53% of queries affected)
2. **Missing indexes on monitoring tables** (540ms queries)
3. **Inefficient multiple pubkey lookups**
Implementing the recommended optimizations will reduce average query time from **10.4ms to 2-3ms** (70-80% improvement) and eliminate the 500-600ms slow queries entirely.
**Priority:** Implement tag indexing system and add missing indexes within 1 week to prevent performance degradation as database grows.
+1 -1
View File
@@ -1 +1 @@
1688521
2266130
+4937
View File
File diff suppressed because it is too large Load Diff
+135 -39
View File
@@ -1,6 +1,11 @@
// Define _GNU_SOURCE to ensure all POSIX features are available
#define _GNU_SOURCE
// Forward declaration for query logging (defined in main.c)
extern void log_query_execution(const char* query_type, const char* sub_id,
const char* client_ip, const char* sql,
long elapsed_us, int rows_returned);
// API module for serving embedded web content and admin API functions
#include <stdio.h>
#include <stdlib.h>
@@ -66,6 +71,10 @@ cJSON* query_event_kind_distribution(void) {
sqlite3_stmt* stmt;
const char* sql = "SELECT kind, COUNT(*) as count FROM events GROUP BY kind ORDER BY count DESC";
// Start timing
struct timespec query_start, query_end;
clock_gettime(CLOCK_MONOTONIC, &query_start);
if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL) != SQLITE_OK) {
DEBUG_ERROR("Failed to prepare event kind distribution query");
return NULL;
@@ -77,8 +86,10 @@ cJSON* query_event_kind_distribution(void) {
cJSON* kinds_array = cJSON_CreateArray();
long long total_events = 0;
int row_count = 0;
while (sqlite3_step(stmt) == SQLITE_ROW) {
row_count++;
int kind = sqlite3_column_int(stmt, 0);
long long count = sqlite3_column_int64(stmt, 1);
total_events += count;
@@ -90,6 +101,13 @@ cJSON* query_event_kind_distribution(void) {
}
sqlite3_finalize(stmt);
// Stop timing and log
clock_gettime(CLOCK_MONOTONIC, &query_end);
long elapsed_us = (query_end.tv_sec - query_start.tv_sec) * 1000000L +
(query_end.tv_nsec - query_start.tv_nsec) / 1000L;
log_query_execution("MONITOR", "event_kinds", NULL, sql, elapsed_us, row_count);
cJSON_AddNumberToObject(distribution, "total_events", total_events);
cJSON_AddItemToObject(distribution, "kinds", kinds_array);
@@ -245,6 +263,10 @@ cJSON* query_subscription_details(void) {
DEBUG_LOG("=== SUBSCRIPTION_DETAILS QUERY DEBUG ===");
DEBUG_LOG("Query: %s", sql);
// Start timing
struct timespec query_start, query_end;
clock_gettime(CLOCK_MONOTONIC, &query_start);
if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL) != SQLITE_OK) {
DEBUG_ERROR("Failed to prepare subscription details query");
return NULL;
@@ -287,6 +309,46 @@ cJSON* query_subscription_details(void) {
cJSON_AddBoolToObject(sub_obj, "active", 1); // All from this view are active
cJSON_AddStringToObject(sub_obj, "wsi_pointer", wsi_pointer ? wsi_pointer : "N/A");
// Extract query stats from per_session_data if wsi is still valid
int db_queries = 0;
int db_rows = 0;
double query_rate = 0.0;
double row_rate = 0.0;
double avg_rows_per_query = 0.0;
if (wsi_pointer && strlen(wsi_pointer) > 2) { // Check for valid pointer string
// Parse wsi pointer from hex string
struct lws* wsi = NULL;
if (sscanf(wsi_pointer, "%p", (void**)&wsi) == 1 && wsi != NULL) {
// Get per_session_data from wsi
struct per_session_data* pss = (struct per_session_data*)lws_wsi_user(wsi);
if (pss) {
db_queries = pss->db_queries_executed;
db_rows = pss->db_rows_returned;
// Calculate rates (per minute)
time_t connection_duration = current_time - pss->query_tracking_start;
if (connection_duration > 0) {
double minutes = connection_duration / 60.0;
query_rate = db_queries / minutes;
row_rate = db_rows / minutes;
}
// Calculate average rows per query
if (db_queries > 0) {
avg_rows_per_query = (double)db_rows / (double)db_queries;
}
}
}
}
// Add query stats to subscription object
cJSON_AddNumberToObject(sub_obj, "db_queries_executed", db_queries);
cJSON_AddNumberToObject(sub_obj, "db_rows_returned", db_rows);
cJSON_AddNumberToObject(sub_obj, "query_rate_per_min", query_rate);
cJSON_AddNumberToObject(sub_obj, "row_rate_per_min", row_rate);
cJSON_AddNumberToObject(sub_obj, "avg_rows_per_query", avg_rows_per_query);
// Parse and add filter JSON if available
if (filter_json) {
cJSON* filters = cJSON_Parse(filter_json);
@@ -311,8 +373,15 @@ cJSON* query_subscription_details(void) {
cJSON_AddItemToObject(subscriptions_data, "data", data);
// Stop timing and log
clock_gettime(CLOCK_MONOTONIC, &query_end);
long elapsed_us = (query_end.tv_sec - query_start.tv_sec) * 1000000L +
(query_end.tv_nsec - query_start.tv_nsec) / 1000L;
log_query_execution("MONITOR", "subscription_details", NULL, sql, elapsed_us, row_count);
// DEBUG: Log final summary
DEBUG_LOG("Total subscriptions found: %d", cJSON_GetArraySize(subscriptions_array));
DEBUG_LOG("Total subscriptions found: %d", row_count);
DEBUG_LOG("=== END SUBSCRIPTION_DETAILS QUERY DEBUG ===");
return subscriptions_data;
@@ -430,10 +499,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) {
@@ -459,10 +528,15 @@ int generate_monitoring_event_for_type(const char* d_tag_value, cJSON* (*query_f
// Monitoring hook called when an event is stored
void monitoring_on_event_stored(void) {
// Check throttling first (cheapest check)
// Check if monitoring is disabled (throttle = 0)
int throttle_seconds = get_monitoring_throttle_seconds();
if (throttle_seconds == 0) {
return; // Monitoring disabled
}
// Check throttling
static time_t last_monitoring_time = 0;
time_t current_time = time(NULL);
int throttle_seconds = get_monitoring_throttle_seconds();
if (current_time - last_monitoring_time < throttle_seconds) {
return;
@@ -481,10 +555,15 @@ void monitoring_on_event_stored(void) {
// Monitoring hook called when subscriptions change (create/close)
void monitoring_on_subscription_change(void) {
// Check throttling first (cheapest check)
// Check if monitoring is disabled (throttle = 0)
int throttle_seconds = get_monitoring_throttle_seconds();
if (throttle_seconds == 0) {
return; // Monitoring disabled
}
// Check throttling
static time_t last_monitoring_time = 0;
time_t current_time = time(NULL);
int throttle_seconds = get_monitoring_throttle_seconds();
if (current_time - last_monitoring_time < throttle_seconds) {
return;
@@ -592,6 +671,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},
@@ -843,10 +929,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) {
@@ -1090,7 +1176,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;
@@ -1109,7 +1195,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");
@@ -1412,7 +1498,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;
@@ -1548,13 +1634,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;
@@ -1582,9 +1668,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;
@@ -1616,9 +1702,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)
@@ -1688,9 +1774,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);
@@ -2143,6 +2229,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.";
}
@@ -2378,7 +2474,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;
@@ -2386,7 +2482,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;
@@ -2535,7 +2631,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) {
@@ -2607,14 +2703,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);
@@ -2673,7 +2769,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;
@@ -2681,7 +2777,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;
@@ -2721,8 +2817,8 @@ int handle_monitoring_command(cJSON* event, const char* command, char* error_mes
char* endptr;
long throttle_seconds = strtol(arg, &endptr, 10);
if (*endptr != '\0' || throttle_seconds < 1 || throttle_seconds > 3600) {
char* response_content = "❌ Invalid throttle value\n\nThrottle must be between 1 and 3600 seconds.";
if (*endptr != '\0' || throttle_seconds < 0 || throttle_seconds > 3600) {
char* response_content = "❌ Invalid throttle value\n\nThrottle must be between 0 and 3600 seconds (0 = disabled).";
return send_admin_response(sender_pubkey, response_content, request_id, error_message, error_size, wsi);
}
+623 -55
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,12 +951,131 @@ 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 ||
strcmp(key, "nip40_expiration_enabled") == 0 ||
strcmp(key, "nip40_expiration_strict") == 0 ||
strcmp(key, "nip40_expiration_filter") == 0) {
strcmp(key, "nip40_expiration_filter") == 0 ||
strcmp(key, "nip17_admin_enabled") == 0) {
if (!is_valid_boolean(value)) {
snprintf(error_msg, error_size, "invalid boolean value '%s' for %s", value, key);
return -1;
@@ -1256,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;
@@ -1320,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");
@@ -1395,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;
@@ -1718,13 +1837,16 @@ 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 ||
strcmp(key, "nip40_expiration_strict") == 0 ||
strcmp(key, "nip40_expiration_filter") == 0 ||
strcmp(key, "nip42_auth_required") == 0) {
strcmp(key, "nip42_auth_required") == 0 ||
strcmp(key, "nip17_admin_enabled") == 0) {
data_type = "boolean";
}
@@ -1937,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");
@@ -1962,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) {
@@ -2052,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
@@ -2129,6 +2251,296 @@ int remove_auth_rule_from_config(const char* rule_type, const char* pattern_type
return (rc == SQLITE_DONE) ? 0 : -1;
}
// ================================
// WEB OF TRUST (WoT) SYNC FUNCTION
// ================================
/**
* Sync Web of Trust whitelist from admin's kind 3 (contact list) event
* Extracts p tags from the admin's most recent kind 3 event and creates
* wot_whitelist auth rules for each followed pubkey.
*
* @return 0 on success, -1 on error
*/
int wot_sync_from_admin_kind3(void) {
if (!g_db) {
DEBUG_ERROR("WoT sync: database not available");
return -1;
}
// Check if WoT is enabled
int wot_level = get_config_int("wot_enabled", 0);
if (wot_level <= 0) {
DEBUG_INFO("WoT sync: wot_enabled is %d, skipping sync", wot_level);
return 0; // WoT disabled, nothing to do
}
// Get admin pubkey from config
const char* admin_pubkey = get_config_value("admin_pubkey");
if (!admin_pubkey) {
DEBUG_ERROR("WoT sync: admin_pubkey not configured");
return -1;
}
// Get relay pubkey from config
const char* relay_pubkey = get_config_value("relay_pubkey");
if (!relay_pubkey) {
free((char*)admin_pubkey);
DEBUG_ERROR("WoT sync: relay_pubkey not configured");
return -1;
}
DEBUG_INFO("WoT sync: Starting sync from admin's kind 3 event (level %d)", wot_level);
sqlite3_stmt* stmt = NULL;
int rc;
int whitelisted_count = 0;
// Step 1: Query p tags from admin's most recent kind 3 event
const char* query_sql =
"SELECT DISTINCT et.tag_value FROM event_tags et "
"JOIN events e ON et.event_id = e.id "
"WHERE e.kind = 3 AND e.pubkey = ? AND et.tag_name = 'p' "
"AND e.created_at = (SELECT MAX(created_at) FROM events WHERE kind = 3 AND pubkey = ?)";
rc = sqlite3_prepare_v2(g_db, query_sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("WoT sync: Failed to prepare p-tag query: %s", sqlite3_errmsg(g_db));
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
sqlite3_bind_text(stmt, 1, admin_pubkey, -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 2, admin_pubkey, -1, SQLITE_STATIC);
// Collect all p tags
char** p_tags = NULL;
int p_tag_count = 0;
int p_tag_capacity = 16;
p_tags = malloc(p_tag_capacity * sizeof(char*));
if (!p_tags) {
DEBUG_ERROR("WoT sync: Failed to allocate p_tag array");
sqlite3_finalize(stmt);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
const char* p_tag = (const char*)sqlite3_column_text(stmt, 0);
if (p_tag) {
// Expand array if needed
if (p_tag_count >= p_tag_capacity) {
p_tag_capacity *= 2;
char** new_tags = realloc(p_tags, p_tag_capacity * sizeof(char*));
if (!new_tags) {
DEBUG_ERROR("WoT sync: Failed to expand p_tag array");
// Free existing tags
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
sqlite3_finalize(stmt);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
p_tags = new_tags;
}
p_tags[p_tag_count] = strdup(p_tag);
p_tag_count++;
}
}
sqlite3_finalize(stmt);
// If no kind 3 event found, just log and continue (admin and relay will still be whitelisted)
if (p_tag_count == 0) {
DEBUG_WARN("WoT sync: No p tags found in admin's kind 3 event");
} else {
DEBUG_INFO("WoT sync: Found %d followed pubkeys in kind 3 event", p_tag_count);
}
// Step 2: Begin transaction
rc = sqlite3_exec(g_db, "BEGIN TRANSACTION", NULL, NULL, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("WoT sync: Failed to begin transaction: %s", sqlite3_errmsg(g_db));
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
// Step 3: Clear existing wot_whitelist rules
const char* delete_sql = "DELETE FROM auth_rules WHERE rule_type = 'wot_whitelist'";
rc = sqlite3_exec(g_db, delete_sql, NULL, NULL, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("WoT sync: Failed to clear existing wot_whitelist rules: %s", sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
DEBUG_TRACE("WoT sync: Cleared existing wot_whitelist rules");
// Step 4: Insert admin pubkey as wot_whitelist (always allowed)
const char* insert_sql =
"INSERT INTO auth_rules (rule_type, pattern_type, pattern_value, active) "
"VALUES ('wot_whitelist', 'pubkey', ?, 1)";
rc = sqlite3_prepare_v2(g_db, insert_sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("WoT sync: Failed to prepare insert statement: %s", sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
sqlite3_bind_text(stmt, 1, admin_pubkey, -1, SQLITE_STATIC);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
DEBUG_ERROR("WoT sync: Failed to insert admin pubkey whitelist: %s", sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
whitelisted_count++;
DEBUG_TRACE("WoT sync: Whitelisted admin pubkey: %.16s...", admin_pubkey);
// Step 5: Insert relay pubkey as wot_whitelist (needed for DM responses)
rc = sqlite3_prepare_v2(g_db, insert_sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("WoT sync: Failed to prepare insert statement for relay: %s", sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
sqlite3_bind_text(stmt, 1, relay_pubkey, -1, SQLITE_STATIC);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
DEBUG_ERROR("WoT sync: Failed to insert relay pubkey whitelist: %s", sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
whitelisted_count++;
DEBUG_TRACE("WoT sync: Whitelisted relay pubkey: %.16s...", relay_pubkey);
// Step 6: Insert each p tag from kind 3 event
for (int i = 0; i < p_tag_count; i++) {
rc = sqlite3_prepare_v2(g_db, insert_sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("WoT sync: Failed to prepare insert for p_tag %d: %s", i, sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
// Free remaining tags
for (int j = i; j < p_tag_count; j++) {
free(p_tags[j]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
sqlite3_bind_text(stmt, 1, p_tags[i], -1, SQLITE_STATIC);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
DEBUG_ERROR("WoT sync: Failed to insert p_tag %d: %s", i, sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
// Free remaining tags
for (int j = i; j < p_tag_count; j++) {
free(p_tags[j]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
whitelisted_count++;
}
// Step 7: Commit transaction
rc = sqlite3_exec(g_db, "COMMIT", NULL, NULL, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("WoT sync: Failed to commit transaction: %s", sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
// Step 8: Enable auth_enabled and optionally nip42_auth_required_subscriptions
if (update_config_in_table("auth_enabled", "true") != 0) {
DEBUG_WARN("WoT sync: Failed to set auth_enabled");
} else {
DEBUG_INFO("WoT sync: Enabled auth_enabled");
}
if (wot_level == 2) {
if (update_config_in_table("nip42_auth_required_subscriptions", "true") != 0) {
DEBUG_WARN("WoT sync: Failed to set nip42_auth_required_subscriptions");
} else {
DEBUG_INFO("WoT sync: Enabled nip42_auth_required_subscriptions for level 2");
}
} else {
// Level 1: ensure nip42_auth_required_subscriptions is false
if (update_config_in_table("nip42_auth_required_subscriptions", "false") != 0) {
DEBUG_WARN("WoT sync: Failed to reset nip42_auth_required_subscriptions");
}
}
// Cleanup
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
DEBUG_INFO("WoT sync: Complete - whitelisted %d pubkeys (admin + relay + %d follows)",
whitelisted_count, p_tag_count);
return 0;
}
// ================================
// UNIFIED TAG PARSING UTILITIES
// ================================
@@ -2137,7 +2549,7 @@ int remove_auth_rule_from_config(const char* rule_type, const char* pattern_type
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);
@@ -2155,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;
@@ -2265,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));
@@ -2379,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));
}
@@ -2473,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");
@@ -2498,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");
@@ -2520,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);
@@ -2553,20 +2965,46 @@ int handle_kind_23456_unified(cJSON* event, char* error_message, size_t error_si
snprintf(error_message, error_size, "error: failed to convert sender public key");
return -1;
}
DEBUG_LOG("=== NIP-44 DECRYPTION DEBUG (Level 5) ===");
DEBUG_LOG("Relay privkey (first 16 chars): %.16s...", relay_privkey);
DEBUG_LOG("Sender pubkey: %s", sender_pubkey);
DEBUG_LOG("Content length: %zu", strlen(content));
DEBUG_LOG("Content (first 100 chars): %.100s%s", content, strlen(content) > 100 ? "..." : "");
// Perform NIP-44 decryption (relay as recipient, admin as sender)
DEBUG_LOG("Calling nostr_nip44_decrypt...");
DEBUG_LOG(" relay_privkey_bytes (first 8 bytes hex): %02x%02x%02x%02x%02x%02x%02x%02x...",
relay_privkey_bytes[0], relay_privkey_bytes[1], relay_privkey_bytes[2], relay_privkey_bytes[3],
relay_privkey_bytes[4], relay_privkey_bytes[5], relay_privkey_bytes[6], relay_privkey_bytes[7]);
DEBUG_LOG(" sender_pubkey_bytes (first 8 bytes hex): %02x%02x%02x%02x%02x%02x%02x%02x...",
sender_pubkey_bytes[0], sender_pubkey_bytes[1], sender_pubkey_bytes[2], sender_pubkey_bytes[3],
sender_pubkey_bytes[4], sender_pubkey_bytes[5], sender_pubkey_bytes[6], sender_pubkey_bytes[7]);
char decrypted_text[16384]; // Buffer for decrypted content (16KB)
int decrypt_result = nostr_nip44_decrypt(relay_privkey_bytes, sender_pubkey_bytes, content, decrypted_text, sizeof(decrypted_text));
DEBUG_LOG("nostr_nip44_decrypt returned: %d (NOSTR_SUCCESS=%d)", decrypt_result, NOSTR_SUCCESS);
// Clean up private key immediately after use
memset(relay_privkey_bytes, 0, 32);
free(relay_privkey);
if (decrypt_result != NOSTR_SUCCESS) {
DEBUG_ERROR("error: NIP-44 decryption failed");
snprintf(error_message, error_size, "error: NIP-44 decryption failed");
DEBUG_ERROR("error: NIP-44 decryption failed with code %d", decrypt_result);
DEBUG_ERROR(" This means the encrypted content cannot be decrypted with the provided keys");
DEBUG_ERROR(" Possible causes:");
DEBUG_ERROR(" 1. Content was encrypted for a different relay pubkey");
DEBUG_ERROR(" 2. Content format is incompatible (wrong NIP-44 version)");
DEBUG_ERROR(" 3. Content is corrupted or malformed");
snprintf(error_message, error_size, "error: NIP-44 decryption failed (code: %d)", decrypt_result);
return -1;
}
DEBUG_LOG("✓ NIP-44 decryption successful!");
DEBUG_LOG("Decrypted text length: %zu", strlen(decrypted_text));
DEBUG_LOG("Decrypted text (first 200 chars): %.200s%s", decrypted_text, strlen(decrypted_text) > 200 ? "..." : "");
DEBUG_LOG("=== END NIP-44 DECRYPTION DEBUG ===");
// Parse decrypted content as command array directly (NOT as NIP-17 inner event)
// Kind 23456 events contain direct command arrays: ["command_name", arg1, arg2, ...]
@@ -2625,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) {
@@ -2813,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) {
@@ -2928,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) {
@@ -2999,6 +3437,20 @@ int handle_config_set_unified(cJSON* event, const char* config_key, const char*
return -1;
}
// Special handling for wot_enabled changes - trigger sync or cleanup
if (strcmp(config_key, "wot_enabled") == 0) {
int new_level = atoi(config_value);
if (new_level > 0) {
DEBUG_INFO("Config set: wot_enabled changed to %d, triggering WoT sync", new_level);
wot_sync_from_admin_kind3();
} else {
// Level 0: clear wot_whitelist rules and reset auth flags
DEBUG_INFO("Config set: wot_enabled changed to 0, clearing wot_whitelist rules");
sqlite3_exec(g_db, "DELETE FROM auth_rules WHERE rule_type = 'wot_whitelist'", NULL, NULL, NULL);
update_config_in_table("auth_enabled", "false");
update_config_in_table("nip42_auth_required_subscriptions", "false");
}
}
// Build response
cJSON* response = cJSON_CreateObject();
@@ -3010,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) {
@@ -3074,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) {
@@ -3151,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) {
@@ -3212,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) {
@@ -3244,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) {
@@ -3266,6 +3718,118 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
snprintf(error_message, error_size, "failed to send restart acknowledgment");
return -1;
}
else if (strcmp(command, "wot_status") == 0) {
// Get admin pubkey from event
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
snprintf(error_message, error_size, "missing admin pubkey for WoT status");
return -1;
}
// Get current WoT level
int wot_enabled = get_config_int("wot_enabled", 0);
// Check if admin's kind 3 event exists
int admin_kind3_exists = 0;
const char* kind3_sql = "SELECT COUNT(*) FROM events WHERE kind = 3 AND pubkey = ?";
sqlite3_stmt* kind3_stmt;
int rc = sqlite3_prepare_v2(g_db, kind3_sql, -1, &kind3_stmt, NULL);
if (rc == SQLITE_OK) {
sqlite3_bind_text(kind3_stmt, 1, admin_pubkey, -1, SQLITE_STATIC);
if (sqlite3_step(kind3_stmt) == SQLITE_ROW) {
admin_kind3_exists = sqlite3_column_int(kind3_stmt, 0) > 0;
}
sqlite3_finalize(kind3_stmt);
}
// Count WoT whitelist entries
int wot_whitelist_count = 0;
const char* count_sql = "SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'wot_whitelist' AND active = 1";
sqlite3_stmt* count_stmt;
if (sqlite3_prepare_v2(g_db, count_sql, -1, &count_stmt, NULL) == SQLITE_OK) {
if (sqlite3_step(count_stmt) == SQLITE_ROW) {
wot_whitelist_count = sqlite3_column_int(count_stmt, 0);
}
sqlite3_finalize(count_stmt);
}
// Build response
cJSON* response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "command", "wot_status");
cJSON_AddStringToObject(response, "status", "success");
cJSON_AddNumberToObject(response, "wot_enabled", wot_enabled);
cJSON_AddBoolToObject(response, "admin_kind3_exists", admin_kind3_exists);
cJSON_AddNumberToObject(response, "wot_whitelist_count", wot_whitelist_count);
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
// Send response as signed kind 23457 event
if (send_admin_response_event(response, admin_pubkey, wsi) == 0) {
cJSON_Delete(response);
return 0;
}
cJSON_Delete(response);
snprintf(error_message, error_size, "failed to send WoT status response");
return -1;
}
else if (strcmp(command, "wot_sync") == 0) {
// Get admin pubkey from event
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
snprintf(error_message, error_size, "missing admin pubkey for WoT sync");
return -1;
}
// Check if WoT is enabled
int wot_enabled = get_config_int("wot_enabled", 0);
if (wot_enabled <= 0) {
snprintf(error_message, error_size, "WoT is not enabled (wot_enabled=0)");
return -1;
}
// Trigger WoT sync
int sync_result = wot_sync_from_admin_kind3();
// Get updated whitelist count
int wot_whitelist_count = 0;
const char* count_sql = "SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'wot_whitelist' AND active = 1";
sqlite3_stmt* count_stmt;
if (sqlite3_prepare_v2(g_db, count_sql, -1, &count_stmt, NULL) == SQLITE_OK) {
if (sqlite3_step(count_stmt) == SQLITE_ROW) {
wot_whitelist_count = sqlite3_column_int(count_stmt, 0);
}
sqlite3_finalize(count_stmt);
}
// Build response
cJSON* response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "command", "wot_sync");
cJSON_AddStringToObject(response, "status", sync_result == 0 ? "success" : "error");
cJSON_AddNumberToObject(response, "wot_enabled", wot_enabled);
cJSON_AddNumberToObject(response, "wot_whitelist_count", wot_whitelist_count);
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
if (sync_result != 0) {
cJSON_AddStringToObject(response, "message", "WoT sync failed");
}
// Send response as signed kind 23457 event
if (send_admin_response_event(response, admin_pubkey, wsi) == 0) {
cJSON_Delete(response);
return sync_result;
}
cJSON_Delete(response);
snprintf(error_message, error_size, "failed to send WoT sync response");
return -1;
}
else {
snprintf(error_message, error_size, "invalid: unknown system command '%s'", command);
return -1;
@@ -3276,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;
@@ -3359,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) {
@@ -3489,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) {
@@ -3557,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;
@@ -3638,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)) {
@@ -3838,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) {
@@ -3866,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) {
@@ -3895,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) {
@@ -4055,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 ||
@@ -4063,7 +4629,8 @@ int populate_all_config_values_atomic(const char* admin_pubkey, const char* rela
strcmp(key, "nip40_expiration_filter") == 0 ||
strcmp(key, "nip42_auth_required_events") == 0 ||
strcmp(key, "nip42_auth_required_subscriptions") == 0 ||
strcmp(key, "nip70_protected_events_enabled") == 0) {
strcmp(key, "nip70_protected_events_enabled") == 0 ||
strcmp(key, "nip17_admin_enabled") == 0) {
data_type = "boolean";
}
@@ -4298,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;
@@ -4348,7 +4915,8 @@ int populate_config_table_from_event(const cJSON* event) {
strcmp(key, "nip40_expiration_enabled") == 0 ||
strcmp(key, "nip40_expiration_strict") == 0 ||
strcmp(key, "nip40_expiration_filter") == 0 ||
strcmp(key, "nip42_auth_required") == 0) {
strcmp(key, "nip42_auth_required") == 0 ||
strcmp(key, "nip17_admin_enabled") == 0) {
data_type = "boolean";
}
@@ -4446,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;
@@ -4648,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;
}
+4 -1
View File
@@ -116,7 +116,10 @@ cJSON* build_query_response(const char* query_type, cJSON* results_array, int to
int add_auth_rule_from_config(const char* rule_type, const char* pattern_type,
const char* pattern_value);
int remove_auth_rule_from_config(const char* rule_type, const char* pattern_type,
const char* pattern_value);
const char* pattern_value);
// Web of Trust (WoT) sync function
int wot_sync_from_admin_kind3(void);
// Unified configuration cache management
void force_config_cache_refresh(void);
+42 -1
View File
@@ -83,11 +83,52 @@ 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", "0"},
// IP Auth Failure Ban Settings
// Ban IPs that repeatedly fail NIP-42 authentication
{"auth_fail_ban_enabled", "true"},
{"auth_fail_ban_threshold", "3"}, // failures before ban
{"auth_fail_window_sec", "60"}, // window to count failures in
{"auth_fail_ban_duration_sec", "300"}, // initial ban duration (doubles each time, max 24h)
// NIP-42 Authentication Timeout
// Seconds after connection before unauthenticated clients are disconnected (0 = disabled)
// Prevents unauthenticated connections from accumulating under heavy load
{"nip42_auth_timeout_sec", "10"},
// Idle Connection Ban Settings
// Ban IPs that connect but never send REQ or EVENT (idle or early disconnect)
{"idle_connection_timeout_sec", "30"}, // Seconds before idle connection is closed (0 = disabled)
{"idle_ban_enabled", "true"}, // Whether to ban IPs with idle failures
{"idle_ban_threshold", "3"}, // Idle failures before ban
{"idle_ban_window_sec", "60"}, // 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"},
// Kind 1 Status Posts
{"kind_1_status_posts_hours", "1"}
{"kind_1_status_posts_hours", "1"},
// Web of Trust Settings
// 0 = off, 1 = write-only (followed pubkeys can publish), 2 = full (followed pubkeys can publish AND subscribe)
{"wot_enabled", "0"},
// NIP-17 Admin DM Settings
// When false (default), Kind 1059 gift wrap events are stored normally without expensive decryption attempts.
// Enable only if you intend to use NIP-17 DMs to send admin commands to the relay.
{"nip17_admin_enabled", "false"}
};
// Number of default configuration values
+162 -10
View File
@@ -37,10 +37,13 @@ extern const char* get_tag_value(cJSON* event, const char* tag_name, int value_i
extern char* get_relay_private_key(void);
extern const char* get_config_value(const char* key);
extern int get_config_bool(const char* key, int default_value);
extern int get_config_int(const char* key, int default_value);
extern int update_config_in_table(const char* key, const char* value);
// Forward declarations for database functions
extern int store_event(cJSON* event);
extern int broadcast_event_to_subscriptions(cJSON* event);
extern int store_event_tags(const char* event_id, cJSON* tags);
// Forward declarations for stats generation (moved to api.c)
extern char* generate_stats_json(void);
@@ -138,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) {
@@ -301,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);
@@ -344,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);
@@ -436,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;
}
@@ -481,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);
@@ -492,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
@@ -598,6 +601,155 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
DEBUG_INFO("DM_ADMIN: Status command processed successfully");
return 0;
}
// Check for WoT (Web of Trust) commands
else if (strstr(content_lower, "wot") != NULL) {
DEBUG_INFO("DM_ADMIN: Processing WoT command");
// Skip "wot" prefix and find the subcommand
char* wot_cmd = strstr(content_lower, "wot");
if (wot_cmd) {
wot_cmd += 3; // Skip "wot"
while (*wot_cmd == ' ') wot_cmd++; // Skip spaces
char response_msg[1024];
int wot_level = get_config_int("wot_enabled", 0);
extern int wot_sync_from_admin_kind3(void);
if (strcmp(wot_cmd, "0") == 0 || strcmp(wot_cmd, "off") == 0) {
// Disable WoT
update_config_in_table("wot_enabled", "0");
update_config_in_table("auth_enabled", "false");
update_config_in_table("nip42_auth_required_subscriptions", "false");
// Clear wot_whitelist rules
const char* delete_sql = "DELETE FROM auth_rules WHERE rule_type = 'wot_whitelist'";
sqlite3_exec(g_db, delete_sql, NULL, NULL, NULL);
snprintf(response_msg, sizeof(response_msg),
"🔓 Web of Trust Disabled\n"
"━━━━━━━━━━━━━━━━━━━━━━\n"
"\n"
"WoT has been turned off.\n"
"\n"
"The relay is now open to all pubkeys for reading and writing.\n"
"Manual whitelist/blacklist rules are preserved.");
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
DEBUG_INFO("DM_ADMIN: WoT disabled");
}
else if (strcmp(wot_cmd, "1") == 0 || strcmp(wot_cmd, "write") == 0) {
// Write-only mode
update_config_in_table("wot_enabled", "1");
wot_sync_from_admin_kind3();
wot_level = get_config_int("wot_enabled", 0);
snprintf(response_msg, sizeof(response_msg),
"✍️ Web of Trust: Write-Only Mode\n"
"━━━━━━━━━━━━━━━━━━━━━━\n"
"\n"
"Level: 1 (Write-only restriction)\n"
"\n"
"Only pubkeys you follow can publish events.\n"
"Anyone can still subscribe and read events.\n"
"\n"
"The relay will now sync from your kind 3 (contact list) event.");
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
DEBUG_INFO("DM_ADMIN: WoT set to write-only mode");
}
else if (strcmp(wot_cmd, "2") == 0 || strcmp(wot_cmd, "full") == 0) {
// Full mode (write + read restriction)
update_config_in_table("wot_enabled", "2");
wot_sync_from_admin_kind3();
wot_level = get_config_int("wot_enabled", 0);
snprintf(response_msg, sizeof(response_msg),
"🔒 Web of Trust: Full Mode\n"
"━━━━━━━━━━━━━━━━━━━━━━\n"
"\n"
"Level: 2 (Full restriction)\n"
"\n"
"Only pubkeys you follow can:\n"
" • Publish events\n"
" • Subscribe to events (requires NIP-42 auth)\n"
"\n"
"This eliminates anonymous subscription churn.\n"
"\n"
"The relay will now sync from your kind 3 (contact list) event.");
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
DEBUG_INFO("DM_ADMIN: WoT set to full mode");
}
else if (strcmp(wot_cmd, "sync") == 0) {
// Force resync
wot_sync_from_admin_kind3();
snprintf(response_msg, sizeof(response_msg),
"🔄 Web of Trust: Sync Complete\n"
"━━━━━━━━━━━━━━━━━━━━━━\n"
"\n"
"WoT whitelist has been refreshed from your\n"
"kind 3 (contact list) event.\n"
"\n"
"Current level: %d", wot_level);
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
DEBUG_INFO("DM_ADMIN: WoT manual sync completed");
}
else if (strcmp(wot_cmd, "status") == 0 || strlen(wot_cmd) == 0) {
// Show status
// Count whitelisted pubkeys
sqlite3_stmt* stmt;
const char* count_sql = "SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'wot_whitelist' AND active = 1";
int whitelist_count = 0;
if (sqlite3_prepare_v2(g_db, count_sql, -1, &stmt, NULL) == SQLITE_OK) {
if (sqlite3_step(stmt) == SQLITE_ROW) {
whitelist_count = sqlite3_column_int(stmt, 0);
}
sqlite3_finalize(stmt);
}
const char* level_str;
if (wot_level == 0) level_str = "Off (open relay)";
else if (wot_level == 1) level_str = "Write-only (followed pubkeys can publish)";
else if (wot_level == 2) level_str = "Full (followed pubkeys can publish AND subscribe)";
else level_str = "Unknown";
snprintf(response_msg, sizeof(response_msg),
"📊 Web of Trust Status\n"
"━━━━━━━━━━━━━━━━━━━━━━\n"
"\n"
"Level: %d\n"
"%s\n"
"\n"
"Whitelisted pubkeys: %d\n"
"\n"
"Commands:\n"
" wot 0/off - Disable WoT\n"
" wot 1/write - Write-only mode\n"
" wot 2/full - Full restriction mode\n"
" wot sync - Force resync from kind 3\n"
" wot status - Show this status",
wot_level, level_str, whitelist_count);
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
DEBUG_INFO("DM_ADMIN: WoT status displayed");
}
else {
// Unknown wot subcommand
snprintf(response_msg, sizeof(response_msg),
"❌ Unknown WoT Command\n"
"━━━━━━━━━━━━━━━━━━━━━━\n"
"\n"
"Usage: wot [command]\n"
"\n"
"Commands:\n"
" 0, off - Disable WoT\n"
" 1, write - Write-only mode\n"
" 2, full - Full restriction mode\n"
" sync - Force resync from kind 3\n"
" status - Show current status");
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
}
}
return 0;
}
else {
DEBUG_INFO("DM_ADMIN: Checking for confirmation or config change requests");
// Check if it's a confirmation response (yes/no)
@@ -652,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");
@@ -694,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
+549
View File
@@ -0,0 +1,549 @@
#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);
}
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;
}
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;
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);
}
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
+450 -88
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();
@@ -229,6 +233,65 @@ void send_notice_message(struct lws* wsi, struct per_session_data* pss, const ch
}
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
// DATABASE QUERY LOGGING
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
/**
* Log database query execution with timing and context
* Only logs at debug level 3 (DEBUG) or higher
* Warns if query takes >10ms (slow query)
*
* @param query_type Type of query (REQ, COUNT, INSERT, CONFIG, etc.)
* @param sub_id Subscription ID (NULL if not applicable)
* @param client_ip Client IP address (NULL if not applicable)
* @param sql SQL query text
* @param elapsed_us Execution time in microseconds
* @param rows_returned Number of rows returned or affected
*/
void log_query_execution(const char* query_type, const char* sub_id,
const char* client_ip, const char* sql,
long elapsed_us, int rows_returned) {
// Only log at debug level 3 (INFO) or higher
if (g_debug_level < DEBUG_LEVEL_INFO) {
return;
}
// Truncate SQL if too long (keep first 500 chars)
char sql_truncated[512];
if (strlen(sql) > 500) {
snprintf(sql_truncated, sizeof(sql_truncated), "%.497s...", sql);
} else {
snprintf(sql_truncated, sizeof(sql_truncated), "%s", sql);
}
// Get timestamp
time_t now = time(NULL);
struct tm* tm_info = localtime(&now);
char timestamp[32];
strftime(timestamp, sizeof(timestamp), "%Y-%m-%d %H:%M:%S", tm_info);
// Log query with all context (direct to stdout/stderr, not through DEBUG_LOG)
fprintf(stderr, "[%s] [QUERY] type=%s sub=%s ip=%s time=%ldus rows=%d sql=%s\n",
timestamp,
query_type,
sub_id ? sub_id : "N/A",
client_ip ? client_ip : "N/A",
elapsed_us,
rows_returned,
sql_truncated);
// Warn if query is slow (>10ms = 10000us)
if (elapsed_us > 10000) {
fprintf(stderr, "[%s] [SLOW_QUERY] %ldms: %s\n",
timestamp, elapsed_us / 1000, sql_truncated);
}
fflush(stderr);
}
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
// DATABASE FUNCTIONS
@@ -370,12 +433,21 @@ int init_database(const char* database_path_override) {
if (!db_version || strcmp(db_version, "5") == 0) {
needs_migration = 1;
} else if (strcmp(db_version, "6") == 0) {
// Database is already at current schema version v6
// Database is at schema version v6 (compatible)
} else if (strcmp(db_version, "7") == 0) {
// Database is at schema version v7 (compatible)
} else if (strcmp(db_version, "8") == 0) {
// Database is at schema version v8 (compatible)
} else if (strcmp(db_version, "9") == 0) {
// Database is at schema version v9 (compatible)
} else if (strcmp(db_version, "10") == 0) {
// Database is at schema version v10 (compatible)
} else if (strcmp(db_version, EMBEDDED_SCHEMA_VERSION) == 0) {
// Database is at current schema version
} else {
char warning_msg[256];
snprintf(warning_msg, sizeof(warning_msg), "Unknown database schema version: %s", db_version);
snprintf(warning_msg, sizeof(warning_msg), "Unknown database schema version: %s (expected %s)",
db_version, EMBEDDED_SCHEMA_VERSION);
DEBUG_WARN(warning_msg);
}
} else {
@@ -498,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;
}
@@ -589,6 +695,46 @@ const char* extract_d_tag_value(cJSON* tags) {
return NULL;
}
// Insert denormalized tags into event_tags table for fast indexed lookups
int store_event_tags(const char* event_id, cJSON* tags) {
if (!g_db || !event_id || !tags || !cJSON_IsArray(tags)) {
return 0; // Not an error if no tags
}
const char* sql = "INSERT INTO event_tags (event_id, tag_name, tag_value, tag_index) VALUES (?, ?, ?, ?)";
sqlite3_stmt* stmt;
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("Failed to prepare event_tags insert: %s", sqlite3_errmsg(g_db));
return -1;
}
int tag_index = 0;
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, tags) {
if (cJSON_IsArray(tag) && cJSON_GetArraySize(tag) >= 2) {
cJSON* name = cJSON_GetArrayItem(tag, 0);
cJSON* value = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(name) && cJSON_IsString(value)) {
sqlite3_reset(stmt);
sqlite3_bind_text(stmt, 1, event_id, -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 2, cJSON_GetStringValue(name), -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 3, cJSON_GetStringValue(value), -1, SQLITE_STATIC);
sqlite3_bind_int(stmt, 4, tag_index);
rc = sqlite3_step(stmt);
if (rc != SQLITE_DONE) {
DEBUG_ERROR("Failed to insert event tag: %s", sqlite3_errmsg(g_db));
}
}
}
tag_index++;
}
sqlite3_finalize(stmt);
return 0;
}
// Store event in database
int store_event(cJSON* event) {
@@ -597,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");
@@ -633,10 +779,18 @@ int store_event(cJSON* event) {
return -1;
}
// Serialize full event JSON for fast retrieval (use PrintUnformatted for compact storage)
char* event_json = cJSON_PrintUnformatted(event);
if (!event_json) {
DEBUG_ERROR("Failed to serialize event to JSON");
free(tags_json);
return -1;
}
// Prepare SQL statement for event insertion
const char* sql =
"INSERT INTO events (id, pubkey, created_at, kind, event_type, content, sig, tags) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
"INSERT INTO events (id, pubkey, created_at, kind, event_type, content, sig, tags, event_json) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
sqlite3_stmt* stmt;
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
@@ -655,13 +809,16 @@ int store_event(cJSON* event) {
sqlite3_bind_text(stmt, 6, cJSON_GetStringValue(content), -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 7, cJSON_GetStringValue(sig), -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 8, tags_json, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 9, event_json, -1, SQLITE_TRANSIENT);
// Execute statement
rc = sqlite3_step(stmt);
if (rc != SQLITE_DONE) {
const char* err_msg = sqlite3_errmsg(g_db);
int extended_errcode = sqlite3_extended_errcode(g_db);
DEBUG_ERROR("INSERT failed: rc=%d, extended_errcode=%d, msg=%s", rc, extended_errcode, err_msg);
if (rc != SQLITE_CONSTRAINT) {
DEBUG_ERROR("INSERT failed: rc=%d, extended_errcode=%d, msg=%s", rc, extended_errcode, err_msg);
}
}
sqlite3_finalize(stmt);
@@ -689,20 +846,104 @@ int store_event(cJSON* event) {
}
free(tags_json);
free(event_json);
return 0; // Not an error, just duplicate
}
char error_msg[256];
snprintf(error_msg, sizeof(error_msg), "Failed to insert event: %s", sqlite3_errmsg(g_db));
DEBUG_ERROR(error_msg);
free(tags_json);
free(event_json);
return -1;
}
free(tags_json);
free(event_json);
// Call monitoring hook after successful event storage
monitoring_on_event_stored();
// After successful event storage, insert denormalized tags
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_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) {
const char* admin_pubkey = get_config_value("admin_pubkey");
if (admin_pubkey && strcmp(cJSON_GetStringValue(pubkey_obj), admin_pubkey) == 0) {
DEBUG_INFO("Admin kind 3 event stored — triggering WoT sync");
extern int wot_sync_from_admin_kind3(void);
wot_sync_from_admin_kind3();
}
if (admin_pubkey) free((char*)admin_pubkey);
}
}
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;
// Check if event_tags is already populated
sqlite3_stmt* check_stmt;
sqlite3_prepare_v2(g_db, "SELECT COUNT(*) FROM event_tags", -1, &check_stmt, NULL);
if (sqlite3_step(check_stmt) == SQLITE_ROW && sqlite3_column_int(check_stmt, 0) > 0) {
sqlite3_finalize(check_stmt);
DEBUG_INFO("event_tags already populated, skipping");
return 0;
}
sqlite3_finalize(check_stmt);
DEBUG_INFO("Populating event_tags from existing events...");
const char* sql = "SELECT id, tags FROM events WHERE tags != '[]'";
sqlite3_stmt* stmt;
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) return -1;
// Use a transaction for bulk insert performance
sqlite3_exec(g_db, "BEGIN TRANSACTION", NULL, NULL, NULL);
int event_count = 0;
while (sqlite3_step(stmt) == SQLITE_ROW) {
const char* event_id = (const char*)sqlite3_column_text(stmt, 0);
const char* tags_json = (const char*)sqlite3_column_text(stmt, 1);
if (event_id && tags_json) {
cJSON* tags = cJSON_Parse(tags_json);
if (tags) {
store_event_tags(event_id, tags);
cJSON_Delete(tags);
event_count++;
}
}
}
sqlite3_finalize(stmt);
sqlite3_exec(g_db, "COMMIT", NULL, NULL, NULL);
DEBUG_INFO("Populated event_tags for %d events", event_count);
return 0;
}
@@ -764,58 +1005,117 @@ cJSON* retrieve_event(const char* event_id) {
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
/**
* Check if filters contain only kind 99999 (NDK ping)
* Returns 1 if all filters only request kind 99999, 0 otherwise
*/
static int is_only_kind_99999_request(cJSON* filters) {
if (!filters || !cJSON_IsArray(filters)) {
return 0;
}
int filter_count = cJSON_GetArraySize(filters);
if (filter_count == 0) {
return 0;
}
for (int i = 0; i < filter_count; i++) {
cJSON* filter = cJSON_GetArrayItem(filters, i);
if (!filter || !cJSON_IsObject(filter)) {
return 0;
}
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;
}
int kinds_count = cJSON_GetArraySize(kinds);
if (kinds_count == 0) {
return 0;
}
for (int j = 0; j < kinds_count; j++) {
cJSON* kind_item = cJSON_GetArrayItem(kinds, j);
if (!cJSON_IsNumber(kind_item)) {
return 0;
}
int kind_val = (int)cJSON_GetNumberValue(kind_item);
if (kind_val != 99999) {
return 0;
}
}
}
return 1; // All filters only contain kind 99999
}
int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, struct per_session_data *pss) {
if (!cJSON_IsArray(filters)) {
DEBUG_ERROR("REQ filters is not an array");
return 0;
}
// Check if this is a kind 99999 (NDK ping) request - these should never count toward rate limiting
int is_ndk_ping = is_only_kind_99999_request(filters);
// EARLY SUBSCRIPTION LIMIT CHECK - Check limits BEFORE any processing
if (pss) {
time_t current_time = time(NULL);
// Check if client is currently rate limited due to excessive failed attempts
if (pss->rate_limit_until > current_time) {
char rate_limit_msg[256];
int remaining_seconds = (int)(pss->rate_limit_until - current_time);
snprintf(rate_limit_msg, sizeof(rate_limit_msg),
"Rate limited due to excessive failed subscription attempts. Try again in %d seconds.", remaining_seconds);
// NDK ping (kind 99999) should not be blocked by rate limiting at all
if (is_ndk_ping) {
// Allow the request through - it will be handled normally and get a proper response
// The subscription will be created but no events will match (which is correct)
DEBUG_TRACE("Allowing kind 99999 NDK ping through despite rate limit");
// Fall through to normal processing (skip rate limit block)
} else {
char rate_limit_msg[256];
int remaining_seconds = (int)(pss->rate_limit_until - current_time);
snprintf(rate_limit_msg, sizeof(rate_limit_msg),
"Rate limited due to excessive failed subscription attempts. Try again in %d seconds.", remaining_seconds);
// Send CLOSED notice for rate limiting
cJSON* closed_msg = cJSON_CreateArray();
cJSON_AddItemToArray(closed_msg, cJSON_CreateString("CLOSED"));
cJSON_AddItemToArray(closed_msg, cJSON_CreateString(sub_id));
cJSON_AddItemToArray(closed_msg, cJSON_CreateString("error: rate limited"));
cJSON_AddItemToArray(closed_msg, cJSON_CreateString(rate_limit_msg));
// Send CLOSED notice for rate limiting
cJSON* closed_msg = cJSON_CreateArray();
cJSON_AddItemToArray(closed_msg, cJSON_CreateString("CLOSED"));
cJSON_AddItemToArray(closed_msg, cJSON_CreateString(sub_id));
cJSON_AddItemToArray(closed_msg, cJSON_CreateString("error: rate limited"));
cJSON_AddItemToArray(closed_msg, cJSON_CreateString(rate_limit_msg));
char* closed_str = cJSON_Print(closed_msg);
if (closed_str) {
size_t closed_len = strlen(closed_str);
unsigned char* buf = malloc(LWS_PRE + closed_len);
if (buf) {
memcpy(buf + LWS_PRE, closed_str, closed_len);
lws_write(wsi, buf + LWS_PRE, closed_len, LWS_WRITE_TEXT);
free(buf);
char* closed_str = cJSON_Print(closed_msg);
if (closed_str) {
size_t closed_len = strlen(closed_str);
unsigned char* buf = malloc(LWS_PRE + closed_len);
if (buf) {
memcpy(buf + LWS_PRE, closed_str, closed_len);
lws_write(wsi, buf + LWS_PRE, closed_len, LWS_WRITE_TEXT);
free(buf);
}
free(closed_str);
}
free(closed_str);
cJSON_Delete(closed_msg);
// Do NOT increment rate limiting counters here - the client is already rate limited.
// Incrementing counters while already blocked would extend the punishment indefinitely,
// especially for benign requests like NDK's kind 99999 pings.
return 0;
}
cJSON_Delete(closed_msg);
// Update rate limiting counters
pss->failed_subscription_attempts++;
pss->last_failed_attempt = current_time;
return 0;
}
// Check session subscription limits
if (pss->subscription_count >= g_subscription_manager.max_subscriptions_per_client) {
DEBUG_ERROR("Maximum subscriptions per client exceeded");
// Update rate limiting counters for failed attempt
pss->failed_subscription_attempts++;
pss->last_failed_attempt = current_time;
pss->consecutive_failures++;
// Update rate limiting counters for failed attempt (but not for NDK ping)
if (!is_ndk_ping) {
pss->failed_subscription_attempts++;
pss->last_failed_attempt = current_time;
pss->consecutive_failures++;
}
// Implement progressive backoff: 1s, 5s, 30s, 300s (5min) based on consecutive failures
int backoff_seconds = 1;
@@ -930,7 +1230,8 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
cJSON_Delete(closed_msg);
// Update rate limiting counters for failed attempt (global limit reached)
if (pss) {
// Do not count NDK ping (kind 99999) toward rate limiting
if (pss && !is_ndk_ping) {
time_t current_time = time(NULL);
pss->failed_subscription_attempts++;
pss->last_failed_attempt = current_time;
@@ -966,7 +1267,8 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
bind_param_capacity = 0;
// Build SQL query based on filter - exclude ephemeral events (kinds 20000-29999) from historical queries
char sql[1024] = "SELECT id, pubkey, created_at, kind, content, sig, tags FROM events WHERE 1=1 AND (kind < 20000 OR kind >= 30000)";
// Select event_json for fast retrieval (no JSON reconstruction needed)
char sql[1024] = "SELECT event_json FROM events WHERE 1=1 AND (kind < 20000 OR kind >= 30000)";
char* sql_ptr = sql + strlen(sql);
int remaining = sizeof(sql) - strlen(sql);
@@ -974,7 +1276,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
// after retrieving events to ensure compatibility with all SQLite versions
// Handle kinds filter
cJSON* kinds = cJSON_GetObjectItem(filter, "kinds");
cJSON* kinds = cJSON_GetObjectItemCaseSensitive(filter, "kinds");
if (kinds && cJSON_IsArray(kinds)) {
int kind_count = cJSON_GetArraySize(kinds);
if (kind_count > 0) {
@@ -1002,7 +1304,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
// Handle authors filter
cJSON* authors = cJSON_GetObjectItem(filter, "authors");
cJSON* authors = cJSON_GetObjectItemCaseSensitive(filter, "authors");
if (authors && cJSON_IsArray(authors)) {
int author_count = 0;
// Count valid authors
@@ -1042,7 +1344,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
// Handle ids filter
cJSON* ids = cJSON_GetObjectItem(filter, "ids");
cJSON* ids = cJSON_GetObjectItemCaseSensitive(filter, "ids");
if (ids && cJSON_IsArray(ids)) {
int id_count = 0;
// Count valid ids
@@ -1099,8 +1401,8 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
}
if (tag_value_count > 0) {
// Use EXISTS with parameterized query
snprintf(sql_ptr, remaining, " AND EXISTS (SELECT 1 FROM json_each(json(tags)) WHERE json_extract(value, '$[0]') = ? AND json_extract(value, '$[1]') IN (");
// Use indexed event_tags table instead of json_each()
snprintf(sql_ptr, remaining, " AND id IN (SELECT event_id FROM event_tags WHERE tag_name = ? AND tag_value IN (");
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
@@ -1132,7 +1434,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
// Handle search filter (NIP-50)
cJSON* search = cJSON_GetObjectItem(filter, "search");
cJSON* search = cJSON_GetObjectItemCaseSensitive(filter, "search");
if (search && cJSON_IsString(search)) {
const char* search_term = cJSON_GetStringValue(search);
if (search_term && strlen(search_term) > 0) {
@@ -1160,7 +1462,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
// Handle since filter
cJSON* since = cJSON_GetObjectItem(filter, "since");
cJSON* since = cJSON_GetObjectItemCaseSensitive(filter, "since");
if (since && cJSON_IsNumber(since)) {
snprintf(sql_ptr, remaining, " AND created_at >= %ld", (long)cJSON_GetNumberValue(since));
sql_ptr += strlen(sql_ptr);
@@ -1168,7 +1470,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
// Handle until filter
cJSON* until = cJSON_GetObjectItem(filter, "until");
cJSON* until = cJSON_GetObjectItemCaseSensitive(filter, "until");
if (until && cJSON_IsNumber(until)) {
snprintf(sql_ptr, remaining, " AND created_at <= %ld", (long)cJSON_GetNumberValue(until));
sql_ptr += strlen(sql_ptr);
@@ -1181,7 +1483,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
remaining = sizeof(sql) - strlen(sql);
// Handle limit filter
cJSON* limit = cJSON_GetObjectItem(filter, "limit");
cJSON* limit = cJSON_GetObjectItemCaseSensitive(filter, "limit");
if (limit && cJSON_IsNumber(limit)) {
int limit_val = (int)cJSON_GetNumberValue(limit);
if (limit_val > 0 && limit_val <= 5000) {
@@ -1192,6 +1494,10 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
snprintf(sql_ptr, remaining, " LIMIT 500");
}
// Start query timing
struct timespec query_start, query_end;
clock_gettime(CLOCK_MONOTONIC, &query_start);
// Execute query and send events
sqlite3_stmt* stmt;
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
@@ -1199,42 +1505,64 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
char error_msg[256];
snprintf(error_msg, sizeof(error_msg), "Failed to prepare subscription query: %s", sqlite3_errmsg(g_db));
DEBUG_ERROR(error_msg);
// Log the failed query so we can see what SQL was generated
if (g_debug_level >= DEBUG_LEVEL_DEBUG) {
time_t now = time(NULL);
struct tm* tm_info = localtime(&now);
char timestamp[32];
strftime(timestamp, sizeof(timestamp), "%Y-%m-%d %H:%M:%S", tm_info);
fprintf(stderr, "[%s] [QUERY_FAILED] type=REQ sub=%s ip=%s error=%s sql=%s\n",
timestamp,
sub_id,
pss ? pss->client_ip : "N/A",
sqlite3_errmsg(g_db),
sql);
fflush(stderr);
}
continue;
}
// Track query execution for abuse detection
if (pss) {
pss->db_queries_executed++;
}
// Bind parameters
for (int i = 0; i < bind_param_count; i++) {
sqlite3_bind_text(stmt, i + 1, bind_params[i], -1, SQLITE_TRANSIENT);
}
// Cache config values outside the row loop (performance fix)
int expiration_enabled = get_config_bool("expiration_enabled", 1);
int filter_responses = get_config_bool("expiration_filter", 1);
int row_count = 0;
while (sqlite3_step(stmt) == SQLITE_ROW) {
row_count++;
// Build event JSON
cJSON* event = cJSON_CreateObject();
cJSON_AddStringToObject(event, "id", (char*)sqlite3_column_text(stmt, 0));
cJSON_AddStringToObject(event, "pubkey", (char*)sqlite3_column_text(stmt, 1));
cJSON_AddNumberToObject(event, "created_at", sqlite3_column_int64(stmt, 2));
cJSON_AddNumberToObject(event, "kind", sqlite3_column_int(stmt, 3));
cJSON_AddStringToObject(event, "content", (char*)sqlite3_column_text(stmt, 4));
cJSON_AddStringToObject(event, "sig", (char*)sqlite3_column_text(stmt, 5));
// Track rows returned for abuse detection
if (pss) {
pss->db_rows_returned++;
}
// Parse tags JSON
const char* tags_json = (char*)sqlite3_column_text(stmt, 6);
cJSON* tags = NULL;
if (tags_json) {
tags = cJSON_Parse(tags_json);
// Get pre-serialized event JSON (no reconstruction needed!)
const char* event_json_str = (char*)sqlite3_column_text(stmt, 0);
if (!event_json_str) {
DEBUG_ERROR("Event has NULL event_json field");
continue;
}
if (!tags) {
tags = cJSON_CreateArray();
// Parse event JSON only for expiration check
cJSON* event = cJSON_Parse(event_json_str);
if (!event) {
DEBUG_ERROR("Failed to parse event_json from database");
continue;
}
cJSON_AddItemToObject(event, "tags", tags);
// Check expiration filtering (NIP-40) at application level
int expiration_enabled = get_config_bool("expiration_enabled", 1);
int filter_responses = get_config_bool("expiration_filter", 1);
// (expiration_enabled and filter_responses are cached outside the loop)
if (expiration_enabled && filter_responses) {
time_t current_time = time(NULL);
if (is_event_expired(event, current_time)) {
@@ -1244,27 +1572,39 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
}
// Send EVENT message
cJSON* event_msg = cJSON_CreateArray();
cJSON_AddItemToArray(event_msg, cJSON_CreateString("EVENT"));
cJSON_AddItemToArray(event_msg, cJSON_CreateString(sub_id));
cJSON_AddItemToArray(event_msg, event);
// 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;
char* msg_str = cJSON_Print(event_msg);
if (msg_str) {
size_t msg_len = strlen(msg_str);
// Use proper message queue system instead of direct lws_write
if (queue_message(wsi, pss, msg_str, msg_len, LWS_WRITE_TEXT) != 0) {
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_msg);
cJSON_Delete(event);
events_sent++;
}
sqlite3_finalize(stmt);
// Stop query timing and log
clock_gettime(CLOCK_MONOTONIC, &query_end);
long elapsed_us = (query_end.tv_sec - query_start.tv_sec) * 1000000L +
(query_end.tv_nsec - query_start.tv_nsec) / 1000L;
log_query_execution("REQ", sub_id, pss ? pss->client_ip : NULL,
sql, elapsed_us, row_count);
}
// Cleanup bind params
@@ -1289,7 +1629,7 @@ int is_authorized_admin_event(cJSON* event, char* error_buffer, size_t error_buf
// Step 1: Verify event kind is admin type
cJSON *kind_json = cJSON_GetObjectItem(event, "kind");
cJSON *kind_json = cJSON_GetObjectItemCaseSensitive(event, "kind");
if (!kind_json || !cJSON_IsNumber(kind_json)) {
snprintf(error_buffer, error_buffer_size, "Missing or invalid event kind");
return -1;
@@ -1302,7 +1642,7 @@ int is_authorized_admin_event(cJSON* event, char* error_buffer, size_t error_buf
}
// Step 2: Check if event targets this relay (look for 'p' tag with our relay pubkey)
cJSON *tags = cJSON_GetObjectItem(event, "tags");
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags || !cJSON_IsArray(tags)) {
// No tags array - treat as regular event for different relay
snprintf(error_buffer, error_buffer_size, "Admin event not targeting this relay (no tags)");
@@ -1337,7 +1677,7 @@ int is_authorized_admin_event(cJSON* event, char* error_buffer, size_t error_buf
}
// Step 3: Verify admin signature authorization
cJSON *pubkey_json = cJSON_GetObjectItem(event, "pubkey");
cJSON *pubkey_json = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (!pubkey_json || !cJSON_IsString(pubkey_json)) {
DEBUG_WARN("Unauthorized admin event attempt: missing or invalid pubkey");
snprintf(error_buffer, error_buffer_size, "Unauthorized admin event attempt: missing pubkey");
@@ -1563,6 +1903,10 @@ int main(int argc, char* argv[]) {
signal(SIGINT, signal_handler);
signal(SIGTERM, signal_handler);
// Print version at startup (always, regardless of debug level)
fprintf(stderr, "[RELAY_VERSION] C Nostr Relay Server %s\n", CRELAY_VERSION);
fflush(stderr);
printf(BLUE BOLD "=== C Nostr Relay Server ===" RESET "\n");
@@ -1808,9 +2152,27 @@ int main(int argc, char* argv[]) {
return 1;
}
// Initialize kind-based index for fast subscription lookup
init_kind_index();
// Populate event_tags from existing events (for tag-based lookups)
populate_event_tags_from_existing();
// Sync Web of Trust whitelist if enabled
int wot_level = get_config_int("wot_enabled", 0);
if (wot_level > 0) {
DEBUG_INFO("WoT enabled (level %d) - syncing from admin's kind 3 event", wot_level);
extern int wot_sync_from_admin_kind3(void);
wot_sync_from_admin_kind3();
}
// 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
+3 -3
View File
@@ -12,9 +12,9 @@
// Version information (auto-updated by build system)
// Using CRELAY_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define CRELAY_VERSION_MAJOR 1
#define CRELAY_VERSION_MINOR 1
#define CRELAY_VERSION_PATCH 3
#define CRELAY_VERSION "v1.1.3"
#define CRELAY_VERSION_MINOR 2
#define CRELAY_VERSION_PATCH 31
#define CRELAY_VERSION "v1.2.31"
// Relay metadata (authoritative source for NIP-11 information)
#define RELAY_NAME "C-Relay"
+6 -6
View File
@@ -33,12 +33,12 @@ int handle_deletion_request(cJSON* event, char* error_message, size_t error_size
}
// Extract event details
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* created_at_obj = cJSON_GetObjectItem(event, "created_at");
cJSON* tags_obj = cJSON_GetObjectItem(event, "tags");
cJSON* content_obj = cJSON_GetObjectItem(event, "content");
cJSON* event_id_obj = cJSON_GetObjectItem(event, "id");
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
cJSON* created_at_obj = cJSON_GetObjectItemCaseSensitive(event, "created_at");
cJSON* tags_obj = cJSON_GetObjectItemCaseSensitive(event, "tags");
cJSON* content_obj = cJSON_GetObjectItemCaseSensitive(event, "content");
cJSON* event_id_obj = cJSON_GetObjectItemCaseSensitive(event, "id");
if (!kind_obj || !pubkey_obj || !created_at_obj || !tags_obj || !event_id_obj) {
snprintf(error_message, error_size, "invalid: incomplete deletion request");
+23 -2
View File
@@ -118,6 +118,8 @@ cJSON* generate_relay_info_json() {
int default_limit = get_config_int("default_limit", 500);
int min_pow_difficulty = get_config_int("pow_min_difficulty", 0);
int admin_enabled = get_config_bool("admin_enabled", 0);
int wot_enabled = get_config_int("wot_enabled", 0);
int auth_enabled = get_config_bool("auth_enabled", 0);
// Add basic relay information
if (relay_name && strlen(relay_name) > 0) {
@@ -209,6 +211,9 @@ cJSON* generate_relay_info_json() {
}
// Add server limitations
// restricted_writes is true when WoT or auth is enabled (only trusted pubkeys can write)
int restricted_writes = (wot_enabled > 0 || auth_enabled) ? 1 : 0;
cJSON* limitation = cJSON_CreateObject();
if (limitation) {
cJSON_AddNumberToObject(limitation, "max_message_length", max_message_length);
@@ -218,15 +223,31 @@ cJSON* generate_relay_info_json() {
cJSON_AddNumberToObject(limitation, "max_event_tags", max_event_tags);
cJSON_AddNumberToObject(limitation, "max_content_length", max_content_length);
cJSON_AddNumberToObject(limitation, "min_pow_difficulty", min_pow_difficulty);
cJSON_AddBoolToObject(limitation, "auth_required", admin_enabled ? cJSON_True : cJSON_False);
cJSON_AddBoolToObject(limitation, "auth_required", auth_enabled ? cJSON_True : cJSON_False);
cJSON_AddBoolToObject(limitation, "payment_required", cJSON_False);
cJSON_AddBoolToObject(limitation, "restricted_writes", cJSON_False);
cJSON_AddBoolToObject(limitation, "restricted_writes", restricted_writes ? cJSON_True : cJSON_False);
cJSON_AddNumberToObject(limitation, "created_at_lower_limit", 0);
cJSON_AddNumberToObject(limitation, "created_at_upper_limit", 2147483647);
cJSON_AddNumberToObject(limitation, "default_limit", default_limit);
cJSON_AddItemToObject(info, "limitation", limitation);
}
// Add Web of Trust information when enabled
// This informs clients that the relay operates on a trust network
if (wot_enabled > 0) {
cJSON* wot_info = cJSON_CreateObject();
if (wot_info) {
cJSON_AddNumberToObject(wot_info, "level", wot_enabled);
const char* level_desc = (wot_enabled == 1)
? "write-only: only followed pubkeys can publish events"
: "full: only followed pubkeys can publish and subscribe";
cJSON_AddStringToObject(wot_info, "description", level_desc);
cJSON_AddStringToObject(wot_info, "policy",
"Events from pubkeys not in the relay operator's Web of Trust are rejected.");
cJSON_AddItemToObject(info, "web_of_trust", wot_info);
}
}
// Add retention policies (empty array for now)
cJSON* retention = cJSON_CreateArray();
if (retention) {
+1 -1
View File
@@ -55,7 +55,7 @@ int validate_event_pow(cJSON* event, char* error_message, size_t error_size) {
// If min_pow_difficulty is 0, only validate events that have nonce tags
// This allows events without PoW when difficulty requirement is 0
if (min_pow_difficulty == 0) {
cJSON* tags = cJSON_GetObjectItem(event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
int has_nonce_tag = 0;
if (tags && cJSON_IsArray(tags)) {
+2 -2
View File
@@ -114,7 +114,7 @@ int is_event_expired(cJSON* event, time_t current_time) {
return 0; // Invalid event, not expired
}
cJSON* tags = cJSON_GetObjectItem(event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
long expiration_ts = extract_expiration_timestamp(tags);
if (expiration_ts == 0) {
@@ -140,7 +140,7 @@ int validate_event_expiration(cJSON* event, char* error_message, size_t error_si
time_t current_time = time(NULL);
if (is_event_expired(event, current_time)) {
if (g_expiration_config.strict_mode) {
cJSON* tags = cJSON_GetObjectItem(event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
long expiration_ts = extract_expiration_timestamp(tags);
snprintf(error_message, error_size,
+11 -2
View File
@@ -13,6 +13,7 @@
#include <stdlib.h>
#include <time.h>
#include "websockets.h"
#include "ip_ban.h"
// Forward declaration for notice message function
@@ -99,7 +100,7 @@ void handle_nip42_auth_signed_event(struct lws* wsi, struct per_session_data* ps
char authenticated_pubkey[65] = {0};
if (result == 0) {
// Extract pubkey from the auth event
cJSON* pubkey_json = cJSON_GetObjectItem(auth_event, "pubkey");
cJSON* pubkey_json = cJSON_GetObjectItemCaseSensitive(auth_event, "pubkey");
if (pubkey_json && cJSON_IsString(pubkey_json)) {
const char* pubkey_str = cJSON_GetStringValue(pubkey_json);
if (pubkey_str && strlen(pubkey_str) == 64) {
@@ -125,8 +126,16 @@ void handle_nip42_auth_signed_event(struct lws* wsi, struct per_session_data* ps
memset(pss->active_challenge, 0, sizeof(pss->active_challenge));
pss->challenge_expires = 0;
pss->auth_challenge_sent = 0;
// Mark session as active - client sent AUTH
pss->session_active = 1;
pthread_mutex_unlock(&pss->session_lock);
// Cancel the auth timeout — client has authenticated, keep connection open
lws_set_timeout(wsi, NO_PENDING_TIMEOUT, 0);
// Record successful auth for this IP — marks it as trusted
ip_ban_record_success(pss->client_ip);
send_notice_message(wsi, pss, "NIP-42 authentication successful");
} else {
// Authentication failed
+15 -15
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;
@@ -595,10 +595,10 @@ int check_database_auth_rules(const char *pubkey, const char *operation __attrib
}
}
// Step 3: Check pubkey whitelist
// Step 3: Check pubkey whitelist (includes wot_whitelist)
const char *whitelist_sql =
"SELECT rule_type FROM auth_rules WHERE rule_type = "
"'whitelist' AND pattern_type = 'pubkey' AND pattern_value = ? AND active = 1 LIMIT 1";
"SELECT rule_type FROM auth_rules WHERE rule_type IN "
"('whitelist', 'wot_whitelist') AND pattern_type = 'pubkey' AND pattern_value = ? AND active = 1 LIMIT 1";
rc = sqlite3_prepare_v2(db, whitelist_sql, -1, &stmt, NULL);
if (rc == SQLITE_OK) {
sqlite3_bind_text(stmt, 1, pubkey, -1, SQLITE_STATIC);
@@ -611,9 +611,9 @@ int check_database_auth_rules(const char *pubkey, const char *operation __attrib
sqlite3_finalize(stmt);
}
// Step 4: Check if any whitelist rules exist - if yes, deny by default
// Step 4: Check if any whitelist rules exist - if yes, deny by default (includes wot_whitelist)
const char *whitelist_exists_sql =
"SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'whitelist' "
"SELECT COUNT(*) FROM auth_rules WHERE rule_type IN ('whitelist', 'wot_whitelist') "
"AND pattern_type = 'pubkey' AND active = 1 LIMIT 1";
rc = sqlite3_prepare_v2(db, whitelist_exists_sql, -1, &stmt, NULL);
if (rc == SQLITE_OK) {
@@ -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) {
+31 -7
View File
@@ -1,11 +1,11 @@
/* Embedded SQL Schema for C Nostr Relay
* Schema Version: 9
* Schema Version: 12
*/
#ifndef SQL_SCHEMA_H
#define SQL_SCHEMA_H
/* Schema version constant */
#define EMBEDDED_SCHEMA_VERSION "9"
#define EMBEDDED_SCHEMA_VERSION "12"
/* Embedded SQL schema as C string literal */
static const char* const EMBEDDED_SCHEMA_SQL =
@@ -14,7 +14,7 @@ static const char* const EMBEDDED_SCHEMA_SQL =
-- Configuration system using config table\n\
\n\
-- Schema version tracking\n\
PRAGMA user_version = 9;\n\
PRAGMA user_version = 12;\n\
\n\
-- Enable foreign key support\n\
PRAGMA foreign_keys = ON;\n\
@@ -34,6 +34,7 @@ CREATE TABLE events (\n\
content TEXT NOT NULL, -- Event content (text content only)\n\
sig TEXT NOT NULL, -- Event signature (hex string)\n\
tags JSON NOT NULL DEFAULT '[]', -- Event tags as JSON array\n\
event_json TEXT NOT NULL, -- Full event JSON (pre-serialized for fast retrieval)\n\
first_seen INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) -- When relay received event\n\
);\n\
\n\
@@ -48,6 +49,25 @@ CREATE INDEX idx_events_kind_created_at ON events(kind, created_at DESC);\n\
CREATE INDEX idx_events_pubkey_created_at ON events(pubkey, created_at DESC);\n\
CREATE INDEX idx_events_pubkey_kind ON events(pubkey, kind);\n\
\n\
-- Denormalized event tags for fast indexed lookups\n\
-- Replaces json_each(json(tags)) queries which cause full JSON parsing per row\n\
CREATE TABLE event_tags (\n\
event_id TEXT NOT NULL,\n\
tag_name TEXT NOT NULL,\n\
tag_value TEXT NOT NULL,\n\
tag_index INTEGER NOT NULL DEFAULT 0,\n\
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE\n\
);\n\
\n\
-- Primary lookup index: find events by tag name + value\n\
CREATE INDEX idx_event_tags_lookup ON event_tags(tag_name, tag_value);\n\
\n\
-- Reverse lookup: find all tags for an event (for cleanup)\n\
CREATE INDEX idx_event_tags_event ON event_tags(event_id);\n\
\n\
-- Composite index for common query pattern: tag + kind (via join)\n\
CREATE INDEX idx_event_tags_value_name ON event_tags(tag_value, tag_name);\n\
\n\
-- Schema information table\n\
CREATE TABLE schema_info (\n\
key TEXT PRIMARY KEY,\n\
@@ -57,8 +77,8 @@ CREATE TABLE schema_info (\n\
\n\
-- Insert schema metadata\n\
INSERT INTO schema_info (key, value) VALUES\n\
('version', '9'),\n\
('description', 'Hybrid Nostr relay schema with fixed active_subscriptions_log view'),\n\
('version', '12'),\n\
('description', 'Added event_tags table for fast indexed tag lookups, replacing json_each() correlated subqueries'),\n\
('created_at', strftime('%s', 'now'));\n\
\n\
-- Helper views for common queries\n\
@@ -128,7 +148,7 @@ CREATE TABLE relay_seckey (\n\
-- Used by request_validator.c for unified validation\n\
CREATE TABLE auth_rules (\n\
id INTEGER PRIMARY KEY AUTOINCREMENT,\n\
rule_type TEXT NOT NULL CHECK (rule_type IN ('whitelist', 'blacklist', 'rate_limit', 'auth_required')),\n\
rule_type TEXT NOT NULL CHECK (rule_type IN ('whitelist', 'blacklist', 'rate_limit', 'auth_required', 'wot_whitelist')),\n\
pattern_type TEXT NOT NULL CHECK (pattern_type IN ('pubkey', 'kind', 'ip', 'global')),\n\
pattern_value TEXT,\n\
active INTEGER NOT NULL DEFAULT 1,\n\
@@ -206,6 +226,10 @@ CREATE INDEX idx_subscriptions_created ON subscriptions(created_at DESC);\n\
CREATE INDEX idx_subscriptions_client ON subscriptions(client_ip);\n\
CREATE INDEX idx_subscriptions_wsi ON subscriptions(wsi_pointer);\n\
\n\
-- Composite index for active_subscriptions_log view optimization\n\
-- Optimizes: WHERE event_type = 'created' AND ended_at IS NULL ORDER BY created_at DESC\n\
CREATE INDEX idx_subscriptions_active_log ON subscriptions(event_type, ended_at, created_at DESC);\n\
\n\
CREATE INDEX idx_subscription_metrics_date ON subscription_metrics(date DESC);\n\
\n\
\n\
@@ -306,4 +330,4 @@ SELECT\n\
FROM events\n\
WHERE created_at >= (strftime('%s', 'now') - 2592000);";
#endif /* SQL_SCHEMA_H */
#endif /* SQL_SCHEMA_H */
+264 -72
View File
@@ -37,6 +37,135 @@ extern int get_config_bool(const char* key, int default_value);
// Global subscription manager
extern subscription_manager_t g_subscription_manager;
/////////////////////////////////////////////////////////////////////////////////////////
// KIND-BASED INDEX FOR FAST SUBSCRIPTION LOOKUP
/////////////////////////////////////////////////////////////////////////////////////////
// Initialize the kind index (called once at startup)
void init_kind_index() {
DEBUG_LOG("Initializing kind index for 65536 possible kinds");
// Initialize all kind index entries to NULL
for (int i = 0; i < 65536; i++) {
g_subscription_manager.kind_index[i] = NULL;
}
// Initialize no-kind-filter list
g_subscription_manager.no_kind_filter_subs = NULL;
DEBUG_LOG("Kind index initialized successfully");
}
// Add a subscription to the kind index for all kinds it's interested in
// Must be called with subscriptions_lock held
void add_subscription_to_kind_index(subscription_t* sub) {
if (!sub) return;
int has_kind_filter = 0;
// Track which kinds we've already added to avoid duplicates
// Use a bitmap for memory efficiency: 65536 bits = 8192 bytes
unsigned char added_kinds[8192] = {0}; // 65536 / 8 = 8192 bytes
// Iterate through all filters in this subscription
subscription_filter_t* filter = sub->filters;
while (filter) {
// Check if this filter has a kinds array
if (filter->kinds && cJSON_IsArray(filter->kinds)) {
has_kind_filter = 1;
// Add subscription to index for each kind in the filter
cJSON* kind_item = NULL;
cJSON_ArrayForEach(kind_item, filter->kinds) {
if (cJSON_IsNumber(kind_item)) {
int kind = (int)cJSON_GetNumberValue(kind_item);
// Bounds check
if (kind < 0 || kind > 65535) {
DEBUG_WARN("add_subscription_to_kind_index: kind %d out of range, skipping", kind);
continue;
}
// Check if we've already added this kind (deduplication)
int byte_index = kind / 8;
int bit_index = kind % 8;
if (added_kinds[byte_index] & (1 << bit_index)) {
DEBUG_TRACE("KIND_INDEX: Skipping duplicate kind %d for subscription '%s'", kind, sub->id);
continue; // Already added this kind
}
// Mark this kind as added
added_kinds[byte_index] |= (1 << bit_index);
// Create new index node
kind_subscription_node_t* node = malloc(sizeof(kind_subscription_node_t));
if (!node) {
DEBUG_ERROR("add_subscription_to_kind_index: failed to allocate node for kind %d", kind);
continue;
}
node->subscription = sub;
node->next = g_subscription_manager.kind_index[kind];
g_subscription_manager.kind_index[kind] = node;
DEBUG_TRACE("KIND_INDEX: Added subscription '%s' to kind %d index", sub->id, kind);
}
}
}
filter = filter->next;
}
// If subscription has no kind filter, add to no-kind-filter list using wrapper node
if (!has_kind_filter) {
no_kind_filter_node_t* node = malloc(sizeof(no_kind_filter_node_t));
if (!node) {
DEBUG_ERROR("add_subscription_to_kind_index: failed to allocate no-kind-filter node");
return;
}
node->subscription = sub;
node->next = g_subscription_manager.no_kind_filter_subs;
g_subscription_manager.no_kind_filter_subs = node;
DEBUG_TRACE("KIND_INDEX: Added subscription '%s' to no-kind-filter list", sub->id);
}
}
// Remove a subscription from the kind index
// Must be called with subscriptions_lock held
void remove_subscription_from_kind_index(subscription_t* sub) {
if (!sub) return;
// Remove from all kind indexes
for (int kind = 0; kind < 65536; kind++) {
kind_subscription_node_t** current = &g_subscription_manager.kind_index[kind];
while (*current) {
if ((*current)->subscription == sub) {
kind_subscription_node_t* to_free = *current;
*current = (*current)->next;
free(to_free);
DEBUG_TRACE("KIND_INDEX: Removed subscription '%s' from kind %d index", sub->id, kind);
// Don't break - subscription might be in index multiple times if it has duplicate kinds
} else {
current = &((*current)->next);
}
}
}
// Remove from no-kind-filter list if present
no_kind_filter_node_t** current = &g_subscription_manager.no_kind_filter_subs;
while (*current) {
if ((*current)->subscription == sub) {
no_kind_filter_node_t* to_free = *current;
*current = (*current)->next;
free(to_free);
DEBUG_TRACE("KIND_INDEX: Removed subscription '%s' from no-kind-filter list", sub->id);
break;
}
current = &((*current)->next);
}
}
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
@@ -63,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);
}
@@ -136,11 +265,14 @@ int validate_subscription_id(const char* sub_id) {
return 0; // Empty or too long
}
// Check for valid characters (alphanumeric, underscore, hyphen, colon, comma)
// Check for valid characters (alphanumeric, underscore, hyphen, colon, comma, plus)
// Plus URL-safe special characters: ! $ % & ( ) * . = ? @ # ~
for (size_t i = 0; i < len; i++) {
char c = sub_id[i];
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '_' || c == '-' || c == ':' || c == ',')) {
(c >= '0' && c <= '9') || c == '_' || c == '-' || c == ':' || c == ',' || c == '+' ||
c == '!' || c == '$' || c == '%' || c == '&' || c == '(' || c == ')' ||
c == '*' || c == '.' || c == '=' || c == '?' || c == '@' || c == '#' || c == '~')) {
return 0; // Invalid character
}
}
@@ -284,6 +416,14 @@ int add_subscription_to_manager(subscription_t* sub) {
g_subscription_manager.total_created++;
}
// Add to kind index for fast lookup (must be done while holding lock)
add_subscription_to_kind_index(sub);
// If we found a duplicate, remove it from the kind index
if (duplicate_old) {
remove_subscription_from_kind_index(duplicate_old);
}
pthread_mutex_unlock(&g_subscription_manager.subscriptions_lock);
// If we replaced an existing subscription, unlink it from the per-session list before freeing
@@ -341,6 +481,9 @@ int remove_subscription_from_manager(const char* sub_id, struct lws* wsi) {
// Match by ID and WebSocket connection
if (strcmp(sub->id, sub_id) == 0 && (!wsi || sub->wsi == wsi)) {
// Remove from kind index first (while still holding lock)
remove_subscription_from_kind_index(sub);
// Remove from list
*current = sub->next;
g_subscription_manager.total_subscriptions--;
@@ -389,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,
@@ -402,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;
@@ -434,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;
}
@@ -461,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;
}
@@ -488,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;
@@ -507,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;
}
@@ -520,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
}
@@ -634,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,
@@ -654,19 +797,47 @@ int broadcast_event_to_subscriptions(cJSON* event) {
temp_sub_t* matching_subs = NULL;
int matching_count = 0;
// Get event kind for index lookup
int event_kind_val = -1;
if (event_kind && cJSON_IsNumber(event_kind)) {
event_kind_val = (int)cJSON_GetNumberValue(event_kind);
}
// First pass: collect matching subscriptions while holding lock
pthread_mutex_lock(&g_subscription_manager.subscriptions_lock);
int total_subs = 0;
subscription_t* count_sub = g_subscription_manager.active_subscriptions;
while (count_sub) {
total_subs++;
count_sub = count_sub->next;
}
DEBUG_TRACE("BROADCAST: Checking %d active subscriptions", total_subs);
// Use kind index for fast lookup instead of checking all subscriptions
subscription_t* candidates_to_check[MAX_TOTAL_SUBSCRIPTIONS];
int candidate_count = 0;
subscription_t* sub = g_subscription_manager.active_subscriptions;
while (sub) {
// Add subscriptions from kind index (if event has valid kind)
if (event_kind_val >= 0 && event_kind_val <= 65535) {
DEBUG_TRACE("BROADCAST: Using kind index for kind=%d", event_kind_val);
kind_subscription_node_t* node = g_subscription_manager.kind_index[event_kind_val];
while (node && candidate_count < MAX_TOTAL_SUBSCRIPTIONS) {
if (node->subscription && node->subscription->active) {
candidates_to_check[candidate_count++] = node->subscription;
}
node = node->next;
}
}
// Add subscriptions with no kind filter (must check against all events)
no_kind_filter_node_t* no_kind_node = g_subscription_manager.no_kind_filter_subs;
while (no_kind_node && candidate_count < MAX_TOTAL_SUBSCRIPTIONS) {
if (no_kind_node->subscription && no_kind_node->subscription->active) {
candidates_to_check[candidate_count++] = no_kind_node->subscription;
}
no_kind_node = no_kind_node->next;
}
DEBUG_TRACE("BROADCAST: Checking %d candidate subscriptions (kind index optimization)", candidate_count);
// Test each candidate subscription
for (int i = 0; i < candidate_count; i++) {
subscription_t* sub = candidates_to_check[i];
if (sub->active && sub->wsi && event_matches_subscription(event, sub)) {
temp_sub_t* temp = malloc(sizeof(temp_sub_t));
if (temp) {
@@ -695,7 +866,6 @@ int broadcast_event_to_subscriptions(cJSON* event) {
DEBUG_ERROR("broadcast_event_to_subscriptions: failed to allocate temp subscription");
}
}
sub = sub->next;
}
pthread_mutex_unlock(&g_subscription_manager.subscriptions_lock);
@@ -703,30 +873,40 @@ int broadcast_event_to_subscriptions(cJSON* event) {
// Second pass: send messages without holding lock
temp_sub_t* current_temp = matching_subs;
while (current_temp) {
// Create EVENT message for this subscription
cJSON* event_msg = cJSON_CreateArray();
cJSON_AddItemToArray(event_msg, cJSON_CreateString("EVENT"));
cJSON_AddItemToArray(event_msg, cJSON_CreateString(current_temp->id));
cJSON_AddItemToArray(event_msg, cJSON_Duplicate(event, 1));
char* msg_str = cJSON_Print(event_msg);
if (msg_str) {
size_t msg_len = strlen(msg_str);
unsigned char* buf = malloc(LWS_PRE + msg_len);
// Serialize event once per subscription using pre-serialized event_json if available,
// otherwise fall back to cJSON serialization.
// Format: ["EVENT","<sub_id>",<event_json>]
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_GetObjectItemCaseSensitive(event, "event_json");
if (event_json_field && cJSON_IsString(event_json_field)) {
event_json_str = cJSON_GetStringValue(event_json_field);
} else {
// Fall back to serializing the event
event_json_allocated = cJSON_PrintUnformatted(event);
event_json_str = event_json_allocated;
}
if (event_json_str) {
size_t sub_id_len = strlen(current_temp->id);
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;
// Zero-copy: allocate with LWS_PRE prefix, write directly, transfer ownership to queue
unsigned char* buf = malloc(LWS_PRE + msg_len + 1);
if (buf) {
memcpy(buf + LWS_PRE, msg_str, msg_len);
// DEBUG: Log WebSocket frame details before sending
DEBUG_TRACE("WS_FRAME_SEND: type=EVENT sub=%s len=%zu data=%.100s%s",
current_temp->id,
msg_len,
msg_str,
msg_len > 100 ? "..." : "");
// Queue message for proper libwebsockets pattern
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);
struct per_session_data* pss = (struct per_session_data*)lws_wsi_user(current_temp->wsi);
if (queue_message(current_temp->wsi, pss, msg_str, msg_len, LWS_WRITE_TEXT) == 0) {
// Message queued successfully
// 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
@@ -735,30 +915,22 @@ int broadcast_event_to_subscriptions(cJSON* event) {
while (update_sub) {
if (update_sub->wsi == current_temp->wsi &&
strcmp(update_sub->id, current_temp->id) == 0 &&
update_sub->active) { // Add active check to prevent use-after-free
update_sub->active) {
update_sub->events_sent++;
break;
}
update_sub = update_sub->next;
}
pthread_mutex_unlock(&g_subscription_manager.subscriptions_lock);
// Log event broadcast to database (optional - can be disabled for performance)
// NOTE: event_broadcasts table removed due to FOREIGN KEY constraint issues
// cJSON* event_id_obj = cJSON_GetObjectItem(event, "id");
// if (event_id_obj && cJSON_IsString(event_id_obj)) {
// log_event_broadcast(cJSON_GetStringValue(event_id_obj), current_temp->id, current_temp->client_ip);
// }
} 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(buf);
}
free(msg_str);
}
cJSON_Delete(event_msg);
if (event_json_allocated) {
free(event_json_allocated);
}
current_temp = current_temp->next;
}
@@ -1289,6 +1461,10 @@ int validate_search_term(const char* search_term, char* error_message, size_t er
/**
* Validate all filter values in a filter object
* Returns:
* 1 = valid
* 0 = invalid (malformed, should count toward rate limit)
* -1 = invalid but benign (e.g., kind 99999 from NDK ping, should not count toward rate limit)
*/
int validate_filter_values(cJSON* filter_json, char* error_message, size_t error_size) {
if (!filter_json || !cJSON_IsObject(filter_json)) {
@@ -1296,8 +1472,10 @@ int validate_filter_values(cJSON* filter_json, char* error_message, size_t error
return 0;
}
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");
@@ -1318,15 +1496,29 @@ int validate_filter_values(cJSON* filter_json, char* error_message, size_t error
}
int kind_val = (int)cJSON_GetNumberValue(kind_item);
// Special case: kind 99999 is used by NDK for ping/connectivity checks
// We reject it but don't count it as a malformed request
if (kind_val == 99999) {
has_kind_99999 = 1;
snprintf(error_message, error_size, "kinds[%d]: invalid event kind %d (used by NDK for ping)", i, kind_val);
continue; // Continue checking other kinds
}
if (kind_val < 0 || kind_val > 65535) { // Reasonable range for event kinds
snprintf(error_message, error_size, "kinds[%d]: invalid event kind %d", i, kind_val);
return 0;
}
}
// If we only found kind 99999 and no other validation errors, return -1 (benign error)
if (has_kind_99999) {
return -1;
}
}
// 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");
@@ -1366,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");
@@ -1408,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");
@@ -1417,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");
@@ -1431,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");
@@ -1445,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");
+21
View File
@@ -63,6 +63,18 @@ struct subscription {
struct subscription* session_next; // Next subscription for this session
};
// Kind index entry - linked list of subscriptions interested in a specific kind
typedef struct kind_subscription_node {
subscription_t* subscription; // Pointer to subscription
struct kind_subscription_node* next; // Next subscription for this kind
} kind_subscription_node_t;
// No-kind-filter list entry - wrapper to avoid corrupting subscription->next pointer
typedef struct no_kind_filter_node {
subscription_t* subscription; // Pointer to subscription
struct no_kind_filter_node* next; // Next subscription in no-kind list
} no_kind_filter_node_t;
// Per-IP connection tracking
typedef struct ip_connection_info {
char ip_address[CLIENT_IP_MAX_LENGTH]; // IP address
@@ -79,6 +91,10 @@ struct subscription_manager {
pthread_mutex_t subscriptions_lock; // Global thread safety
int total_subscriptions; // Current count
// Kind-based index for fast subscription lookup (10x performance improvement)
kind_subscription_node_t* kind_index[65536]; // Array of subscription lists, one per kind
no_kind_filter_node_t* no_kind_filter_subs; // Subscriptions with no kind filter (wrapper nodes)
// Configuration
int max_subscriptions_per_client; // Default: 20
int max_total_subscriptions; // Default: 5000
@@ -104,6 +120,11 @@ int event_matches_filter(cJSON* event, subscription_filter_t* filter);
int event_matches_subscription(cJSON* event, subscription_t* subscription);
int broadcast_event_to_subscriptions(cJSON* event);
// Kind index functions for performance optimization
void init_kind_index(void);
void add_subscription_to_kind_index(subscription_t* sub);
void remove_subscription_from_kind_index(subscription_t* sub);
// Per-IP connection tracking functions
ip_connection_info_t* get_or_create_ip_connection(const char* client_ip);
void update_ip_connection_activity(const char* client_ip);
+519 -83
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);
@@ -86,6 +88,11 @@ int is_event_expired(cJSON* event, time_t current_time);
int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, struct per_session_data *pss);
int handle_count_message(const char* sub_id, cJSON* filters, struct lws *wsi, struct per_session_data *pss);
// Forward declaration for query logging (defined in main.c)
extern void log_query_execution(const char* query_type, const char* sub_id,
const char* client_ip, const char* sql,
long elapsed_us, int rows_returned);
// Forward declarations for rate limiting
int is_client_rate_limited_for_malformed_requests(struct per_session_data *pss);
void record_malformed_request(struct per_session_data *pss);
@@ -109,6 +116,81 @@ extern struct lws_context *ws_context;
// Global subscription manager
struct subscription_manager g_subscription_manager;
// Global connection list for idle connection tracking
// Tracks ALL WebSocket connections (not just subscribed ones)
// so the periodic timer can find and close idle connections
#define MAX_TRACKED_CONNECTIONS 4096
typedef struct {
struct lws* wsi;
struct per_session_data* pss;
} tracked_connection_t;
static tracked_connection_t g_connections[MAX_TRACKED_CONNECTIONS];
static int g_connection_count = 0;
static pthread_mutex_t g_connections_lock = PTHREAD_MUTEX_INITIALIZER;
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
@@ -130,6 +212,14 @@ int queue_message(struct lws* wsi, struct per_session_data* pss, const char* mes
return -1;
}
// Drop message if queue is full to prevent unbounded memory growth under load.
// Slow or disconnected clients should not cause the relay to OOM.
if (pss->message_queue_count >= MAX_MESSAGE_QUEUE_SIZE) {
DEBUG_WARN("queue_message: queue full (%d), dropping message for slow/disconnected client",
pss->message_queue_count);
return -1;
}
// Allocate message queue node
struct message_queue_node* node = malloc(sizeof(struct message_queue_node));
if (!node) {
@@ -181,6 +271,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.
@@ -391,6 +541,11 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
memset(pss, 0, sizeof(*pss));
pthread_mutex_init(&pss->session_lock, NULL);
// Initialize database query tracking
pss->db_queries_executed = 0;
pss->db_rows_returned = 0;
pss->query_tracking_start = time(NULL);
// Get real client IP address
char client_ip[CLIENT_IP_MAX_LENGTH];
memset(client_ip, 0, sizeof(client_ip));
@@ -466,6 +621,47 @@ 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;
// 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;
@@ -562,6 +758,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)) {
@@ -591,6 +795,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);
@@ -636,7 +861,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) {
@@ -663,7 +888,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 ||
@@ -680,7 +905,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);
@@ -758,44 +983,58 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
}
}
} else if (event_kind == 1059) {
// Check for NIP-17 gift wrap admin messages
// NIP-17 gift wrap events
// Admin DM processing is opt-in via nip17_admin_enabled config (default: off)
// to prevent expensive decryption on every incoming gift wrap event
int nip17_admin_enabled = get_config_bool("nip17_admin_enabled", 0);
if (nip17_admin_enabled) {
char nip17_error[512] = {0};
cJSON* response_event = process_nip17_admin_message(event, nip17_error, sizeof(nip17_error), wsi);
char nip17_error[512] = {0};
cJSON* response_event = process_nip17_admin_message(event, nip17_error, sizeof(nip17_error), wsi);
if (!response_event) {
// Check if this is an error or if the command was already handled
if (strlen(nip17_error) > 0) {
// There was an actual error
DEBUG_ERROR("NIP-17 admin message processing failed");
result = -1;
size_t error_len = strlen(nip17_error);
size_t copy_len = (error_len < sizeof(error_message) - 1) ? error_len : sizeof(error_message) - 1;
memcpy(error_message, nip17_error, copy_len);
error_message[copy_len] = '\0';
if (!response_event) {
// Check if this is an error or if the command was already handled
if (strlen(nip17_error) > 0) {
// There was an actual error
DEBUG_ERROR("NIP-17 admin message processing failed");
result = -1;
size_t error_len = strlen(nip17_error);
size_t copy_len = (error_len < sizeof(error_message) - 1) ? error_len : sizeof(error_message) - 1;
memcpy(error_message, nip17_error, copy_len);
error_message[copy_len] = '\0';
} else {
// No error message means the command was already handled (plain text commands)
// Store the original gift wrap event in database
if (store_event(event) != 0) {
DEBUG_ERROR("Failed to store gift wrap event in database");
result = -1;
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
}
}
} else {
// No error message means the command was already handled (plain text commands)
// Store the original gift wrap event in database
// Store the original gift wrap event in database (unlike kind 23456)
if (store_event(event) != 0) {
DEBUG_ERROR("Failed to store gift wrap event in database");
result = -1;
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
cJSON_Delete(response_event);
} else {
// Broadcast RESPONSE event to matching persistent subscriptions
broadcast_event_to_subscriptions(response_event);
// Clean up response event
cJSON_Delete(response_event);
}
}
} else {
// Store the original gift wrap event in database (unlike kind 23456)
// NIP-17 admin DMs disabled: store gift wrap as a regular event
DEBUG_TRACE("NIP-17 admin DMs disabled - storing kind 1059 event without decryption");
if (store_event(event) != 0) {
DEBUG_ERROR("Failed to store gift wrap event in database");
result = -1;
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
cJSON_Delete(response_event);
strncpy(error_message, "error: failed to store event", sizeof(error_message) - 1);
} else {
// Broadcast RESPONSE event to matching persistent subscriptions
broadcast_event_to_subscriptions(response_event);
// Clean up response event
cJSON_Delete(response_event);
broadcast_event_to_subscriptions(event);
}
}
} else if (event_kind == 14) {
@@ -858,7 +1097,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"));
@@ -897,12 +1136,49 @@ 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
// and should not be freed here - it will be cleaned up in LWS_CALLBACK_CLOSED
return 0;
}
// WoT read restriction check (wot_enabled == 2)
// After NIP-42 auth, check if authenticated pubkey is in WoT whitelist
if (pss && pss->authenticated && get_config_int("wot_enabled", 0) == 2) {
// Client is authenticated - check if their pubkey is in the WoT whitelist
extern int check_database_auth_rules(const char* pubkey, const char* operation, const char* resource_hash);
int wot_result = check_database_auth_rules(pss->authenticated_pubkey, "subscription", NULL);
if (wot_result != 0) {
send_notice_message(wsi, pss, "restricted: your pubkey is not in this relay's web of trust");
DEBUG_INFO("REQ rejected: pubkey %s not in WoT whitelist", pss->authenticated_pubkey);
cJSON_Delete(json);
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");
@@ -965,11 +1241,15 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
// Validate filters before processing
char filter_error[512] = {0};
if (!validate_filter_array(filters, filter_error, sizeof(filter_error))) {
int validation_result = validate_filter_array(filters, filter_error, sizeof(filter_error));
if (validation_result <= 0) {
DEBUG_TRACE("REQ rejected: filter validation failed - %s", filter_error);
send_notice_message(wsi, pss, filter_error);
DEBUG_WARN("REQ rejected: invalid filters");
record_malformed_request(pss);
// Only record as malformed if it's a true error (0), not benign error (-1)
if (validation_result == 0) {
record_malformed_request(pss);
}
cJSON_Delete(filters);
cJSON_Delete(json);
// Note: complete_message points to reassembly_buffer, which is managed separately
@@ -1050,10 +1330,14 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
// Validate filters before processing
char filter_error[512] = {0};
if (!validate_filter_array(filters, filter_error, sizeof(filter_error))) {
int validation_result = validate_filter_array(filters, filter_error, sizeof(filter_error));
if (validation_result <= 0) {
send_notice_message(wsi, pss, filter_error);
DEBUG_WARN("COUNT rejected: invalid filters");
record_malformed_request(pss);
// Only record as malformed if it's a true error (0), not benign error (-1)
if (validation_result == 0) {
record_malformed_request(pss);
}
cJSON_Delete(filters);
cJSON_Delete(json);
// Note: complete_message points to reassembly_buffer, which is managed separately
@@ -1193,15 +1477,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
@@ -1210,7 +1502,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) {
@@ -1290,6 +1582,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);
@@ -1336,7 +1647,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) {
@@ -1363,7 +1674,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 ||
@@ -1380,7 +1691,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);
@@ -1458,44 +1769,58 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
}
}
} else if (event_kind == 1059) {
// Check for NIP-17 gift wrap admin messages
// NIP-17 gift wrap events
// Admin DM processing is opt-in via nip17_admin_enabled config (default: off)
// to prevent expensive decryption on every incoming gift wrap event
int nip17_admin_enabled = get_config_bool("nip17_admin_enabled", 0);
if (nip17_admin_enabled) {
char nip17_error[512] = {0};
cJSON* response_event = process_nip17_admin_message(event, nip17_error, sizeof(nip17_error), wsi);
char nip17_error[512] = {0};
cJSON* response_event = process_nip17_admin_message(event, nip17_error, sizeof(nip17_error), wsi);
if (!response_event) {
// Check if this is an error or if the command was already handled
if (strlen(nip17_error) > 0) {
// There was an actual error
DEBUG_ERROR("NIP-17 admin message processing failed");
result = -1;
size_t error_len = strlen(nip17_error);
size_t copy_len = (error_len < sizeof(error_message) - 1) ? error_len : sizeof(error_message) - 1;
memcpy(error_message, nip17_error, copy_len);
error_message[copy_len] = '\0';
if (!response_event) {
// Check if this is an error or if the command was already handled
if (strlen(nip17_error) > 0) {
// There was an actual error
DEBUG_ERROR("NIP-17 admin message processing failed");
result = -1;
size_t error_len = strlen(nip17_error);
size_t copy_len = (error_len < sizeof(error_message) - 1) ? error_len : sizeof(error_message) - 1;
memcpy(error_message, nip17_error, copy_len);
error_message[copy_len] = '\0';
} else {
// No error message means the command was already handled (plain text commands)
// Store the original gift wrap event in database
if (store_event(event) != 0) {
DEBUG_ERROR("Failed to store gift wrap event in database");
result = -1;
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
}
}
} else {
// No error message means the command was already handled (plain text commands)
// Store the original gift wrap event in database
// Store the original gift wrap event in database (unlike kind 23456)
if (store_event(event) != 0) {
DEBUG_ERROR("Failed to store gift wrap event in database");
result = -1;
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
cJSON_Delete(response_event);
} else {
// Broadcast RESPONSE event to matching persistent subscriptions
broadcast_event_to_subscriptions(response_event);
// Clean up response event
cJSON_Delete(response_event);
}
}
} else {
// Store the original gift wrap event in database (unlike kind 23456)
// NIP-17 admin DMs disabled: store gift wrap as a regular event
DEBUG_TRACE("NIP-17 admin DMs disabled - storing kind 1059 event without decryption");
if (store_event(event) != 0) {
DEBUG_ERROR("Failed to store gift wrap event in database");
result = -1;
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
cJSON_Delete(response_event);
strncpy(error_message, "error: failed to store event", sizeof(error_message) - 1);
} else {
// Broadcast RESPONSE event to matching persistent subscriptions
broadcast_event_to_subscriptions(response_event);
// Clean up response event
cJSON_Delete(response_event);
broadcast_event_to_subscriptions(event);
}
}
} else if (event_kind == 14) {
@@ -1558,7 +1883,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"));
@@ -1599,6 +1924,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);
@@ -1611,9 +1952,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) {
@@ -1663,11 +2012,15 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
// Validate filters before processing
char filter_error[512] = {0};
if (!validate_filter_array(filters, filter_error, sizeof(filter_error))) {
int validation_result = validate_filter_array(filters, filter_error, sizeof(filter_error));
if (validation_result <= 0) {
DEBUG_TRACE("REQ rejected: filter validation failed - %s", filter_error);
send_notice_message(wsi, pss, filter_error);
DEBUG_WARN("REQ rejected: invalid filters");
record_malformed_request(pss);
// Only record as malformed if it's a true error (0), not benign error (-1)
if (validation_result == 0) {
record_malformed_request(pss);
}
cJSON_Delete(filters);
cJSON_Delete(json);
free(message);
@@ -1746,10 +2099,14 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
// Validate filters before processing
char filter_error[512] = {0};
if (!validate_filter_array(filters, filter_error, sizeof(filter_error))) {
int validation_result = validate_filter_array(filters, filter_error, sizeof(filter_error));
if (validation_result <= 0) {
send_notice_message(wsi, pss, filter_error);
DEBUG_WARN("COUNT rejected: invalid filters");
record_malformed_request(pss);
// Only record as malformed if it's a true error (0), not benign error (-1)
if (validation_result == 0) {
record_malformed_request(pss);
}
cJSON_Delete(filters);
cJSON_Delete(json);
free(message);
@@ -1870,7 +2227,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
@@ -1883,6 +2243,25 @@ 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)
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);
}
// Format authentication status
char auth_status[80];
@@ -2049,6 +2428,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
@@ -2245,11 +2629,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);
}
}
}
@@ -2268,7 +2671,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;
@@ -2304,7 +2707,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;
@@ -2343,7 +2746,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;
@@ -2429,7 +2832,7 @@ int process_dm_stats_command(cJSON* dm_event, char* error_message, size_t error_
// Handle NIP-45 COUNT message
int handle_count_message(const char* sub_id, cJSON* filters, struct lws *wsi, struct per_session_data *pss) {
(void)pss; // Suppress unused parameter warning
// pss is now used for query tracking, so remove unused warning suppression
if (!cJSON_IsArray(filters)) {
DEBUG_ERROR("COUNT filters is not an array");
@@ -2469,7 +2872,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) {
@@ -2497,7 +2900,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
@@ -2541,7 +2944,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
@@ -2643,7 +3046,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) {
@@ -2671,7 +3074,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);
@@ -2679,7 +3082,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);
@@ -2687,6 +3090,10 @@ int handle_count_message(const char* sub_id, cJSON* filters, struct lws *wsi, st
}
// Execute count query
// Start query timing
struct timespec query_start, query_end;
clock_gettime(CLOCK_MONOTONIC, &query_start);
// Execute count query
sqlite3_stmt* stmt;
@@ -2711,6 +3118,15 @@ int handle_count_message(const char* sub_id, cJSON* filters, struct lws *wsi, st
// Filter count calculated
sqlite3_finalize(stmt);
// Stop query timing and log
clock_gettime(CLOCK_MONOTONIC, &query_end);
long elapsed_us = (query_end.tv_sec - query_start.tv_sec) * 1000000L +
(query_end.tv_nsec - query_start.tv_nsec) / 1000L;
log_query_execution("COUNT", sub_id, pss ? pss->client_ip : NULL,
sql, elapsed_us, 1); // COUNT always returns 1 row
total_count += filter_count;
}
@@ -2848,6 +3264,10 @@ int is_valid_hex_string(const char* str, size_t expected_len) {
/**
* Validate a filter array for REQ and COUNT messages
* Returns:
* 1 = valid
* 0 = invalid (malformed, should count toward rate limit)
* -1 = invalid but benign (e.g., kind 99999 from NDK ping, should not count toward rate limit)
*/
int validate_filter_array(cJSON* filters, char* error_message, size_t error_size) {
if (!filters || !cJSON_IsArray(filters)) {
@@ -2861,6 +3281,8 @@ int validate_filter_array(cJSON* filters, char* error_message, size_t error_size
return 0;
}
int has_kind_99999 = 0; // Track if we encounter kind 99999 (NDK ping)
// Validate each filter object
for (int i = 0; i < filter_count; i++) {
cJSON* filter = cJSON_GetArrayItem(filters, i);
@@ -2941,6 +3363,15 @@ int validate_filter_array(cJSON* filters, char* error_message, size_t error_size
return 0;
}
int kind_val = (int)cJSON_GetNumberValue(kind);
// Special case: kind 99999 is used by NDK (Nostr Development Kit) for ping/connectivity checks
// We reject it but don't count it as a malformed request to avoid rate limiting NDK clients
if (kind_val == 99999) {
has_kind_99999 = 1;
snprintf(error_message, error_size, "error: invalid kind value %d (NDK ping)", kind_val);
continue; // Continue checking other kinds
}
if (kind_val < 0 || kind_val > MAX_KIND_VALUE) {
snprintf(error_message, error_size, "error: invalid kind value %d", kind_val);
return 0;
@@ -3018,5 +3449,10 @@ int validate_filter_array(cJSON* filters, char* error_message, size_t error_size
}
}
// If we found kind 99999 (NDK ping), return -1 to indicate benign error
if (has_kind_99999) {
return -1;
}
return 1; // All filters valid
}
+18
View File
@@ -19,6 +19,11 @@
#define MALFORMED_REQUEST_BLOCK_DURATION 3600 // 1 hour in seconds
#define RATE_LIMIT_CLEANUP_INTERVAL 300 // 5 minutes
// Maximum number of messages allowed in a per-client write queue.
// When exceeded, new messages are dropped to prevent unbounded memory growth
// under high-traffic or slow-client conditions.
#define MAX_MESSAGE_QUEUE_SIZE 500
// Filter validation constants
#define MAX_FILTERS_PER_REQUEST 10
#define MAX_AUTHORS_PER_FILTER 1000
@@ -79,6 +84,15 @@ struct per_session_data {
size_t reassembly_size; // Current size of accumulated data
size_t reassembly_capacity; // Allocated capacity of reassembly buffer
int reassembly_active; // Flag: 1 if currently reassembling a message
// Database query tracking for abuse detection and monitoring
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)
};
// NIP-11 HTTP session data structure for managing buffer lifetime
@@ -97,6 +111,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);
+22 -7
View File
@@ -329,12 +329,12 @@ run_count_test() {
fi
# Test 5: Count events with specific tags
# NOTE: Tag filtering is currently not working in the relay - should return the tagged events
local expected_type_regular=$((0 + BASELINE_TYPE_REGULAR)) # Currently returns 0 due to tag filtering bug
# Tag filtering now works with event_tags table
local expected_type_regular=$((3 + BASELINE_TYPE_REGULAR)) # 3 new regular events have type=regular tag
if ! test_count "count_tag_type" '{"#type":["regular"]}' "Count events with type=regular tag" "$expected_type_regular"; then
((test_failures++))
fi
local expected_test_nip45=$((0 + BASELINE_TEST_NIP45)) # Currently returns 0 due to tag filtering bug
local expected_test_nip45=$((3 + BASELINE_TEST_NIP45)) # 3 new regular events have test=nip45 tag
if ! test_count "count_tag_test" '{"#test":["nip45"]}' "Count events with test=nip45 tag" "$expected_test_nip45"; then
((test_failures++))
fi
@@ -362,8 +362,8 @@ run_count_test() {
fi
# Test 9: Count with multiple filters combined
# NOTE: Combined tag filtering is currently not working in the relay
local expected_combined=$((0 + BASELINE_COMBINED)) # Currently returns 0 due to tag filtering bug
# Combined tag+kind filtering now works with event_tags table
local expected_combined=$((3 + BASELINE_COMBINED)) # 3 new regular events match all criteria
if ! test_count "count_combined" '{"kinds":[1],"#type":["regular"],"#test":["nip45"]}' "Count with combined filters" "$expected_combined"; then
((test_failures++))
fi
@@ -379,8 +379,23 @@ run_count_test() {
((test_failures++))
fi
# Test 12: Count non-existent kind
if ! test_count "count_nonexistent" '{"kinds":[99999]}' "Count non-existent kind" "0"; then
# Test 12: Count non-existent kind (kind 99999 is NDK ping, rejected with NOTICE)
# The relay correctly rejects invalid kinds with a NOTICE instead of COUNT
print_step "Testing COUNT: Count non-existent kind (kind 99999 - NDK ping)"
local count_message='["COUNT","count_nonexistent",{"kinds":[99999]}]'
local response=$(echo "$count_message" | timeout 3s websocat "$RELAY_URL" 2>/dev/null || echo "")
if echo "$response" | grep -q '"NOTICE"'; then
print_success "Count non-existent kind - Correctly rejected with NOTICE"
elif echo "$response" | grep -q '"COUNT"'; then
local actual_count=$(echo "$response" | jq -r '.[2].count' 2>/dev/null)
if [[ "$actual_count" == "0" ]]; then
print_success "Count non-existent kind - Expected: 0, Got: 0"
else
print_error "Count non-existent kind - Expected: 0, Got: $actual_count"
((test_failures++))
fi
else
print_error "Count non-existent kind - No response from relay"
((test_failures++))
fi
+270
View File
@@ -0,0 +1,270 @@
#!/bin/bash
# Bulk Event Retrieval Performance Test
# Tests retrieving hundreds of events to measure JSON reconstruction performance
# Load test keys
source tests/.test_keys.txt
RELAY_URL="${RELAY_URL:-ws://localhost:8888}"
NUM_EVENTS=500
# Use test secret keys for creating valid events
SECRET_KEYS=(
"3fdd8227a920c2385559400b2b14e464f22e80df312a73cc7a86e1d7e91d608f"
"a156011cd65b71f84b4a488ac81687f2aed57e490b31c28f58195d787030db60"
"1618aaa21f5bd45c5ffede0d9a60556db67d4a046900e5f66b0bae5c01c801fb"
)
echo "=========================================="
echo "Bulk Event Retrieval Performance Test"
echo "=========================================="
echo "Relay: $RELAY_URL"
echo "Target: Retrieve $NUM_EVENTS events"
echo ""
# Check if relay is running
echo "Checking if relay is running..."
if ! nc -z localhost 8888 2>/dev/null; then
echo "ERROR: Relay is not running on port 8888"
exit 1
fi
echo "✓ Relay is running"
echo ""
# Check if nak is installed
if ! command -v nak &> /dev/null; then
echo "ERROR: 'nak' command not found. Please install nak:"
echo " go install github.com/fiatjaf/nak@latest"
exit 1
fi
# Check current event count in database
DB_FILE=$(ls build/*.db 2>/dev/null | head -1)
if [ -n "$DB_FILE" ]; then
CURRENT_COUNT=$(sqlite3 "$DB_FILE" "SELECT COUNT(*) FROM events WHERE kind=1;" 2>/dev/null || echo "0")
echo "Current kind 1 events in database: $CURRENT_COUNT"
if [ "$CURRENT_COUNT" -ge "$NUM_EVENTS" ]; then
echo "✓ Database already has $CURRENT_COUNT events (>= $NUM_EVENTS required)"
echo " Skipping event posting..."
echo ""
else
EVENTS_TO_POST=$((NUM_EVENTS - CURRENT_COUNT))
echo "Need to post $EVENTS_TO_POST more events..."
echo ""
# Post additional events
echo "Posting $EVENTS_TO_POST test events using nak..."
for i in $(seq 1 $EVENTS_TO_POST); do
# Cycle through secret keys
KEY_INDEX=$(( (i - 1) % ${#SECRET_KEYS[@]} ))
CURRENT_KEY=${SECRET_KEYS[$KEY_INDEX]}
# Create content
CONTENT="Bulk test event $i/$EVENTS_TO_POST for performance testing"
# Post event using nak (properly signed)
nak event -c "$CONTENT" --sec "$CURRENT_KEY" "$RELAY_URL" >/dev/null 2>&1
# Progress indicator
if [ $((i % 50)) -eq 0 ]; then
echo " Posted $i/$EVENTS_TO_POST events..."
fi
done
echo "✓ Posted $EVENTS_TO_POST test events"
echo ""
fi
else
echo "WARNING: Could not find database file"
echo "Posting $NUM_EVENTS events anyway..."
echo ""
# Post events
echo "Posting $NUM_EVENTS test events using nak..."
for i in $(seq 1 $NUM_EVENTS); do
KEY_INDEX=$(( (i - 1) % ${#SECRET_KEYS[@]} ))
CURRENT_KEY=${SECRET_KEYS[$KEY_INDEX]}
CONTENT="Bulk test event $i/$NUM_EVENTS for performance testing"
nak event -c "$CONTENT" --sec "$CURRENT_KEY" "$RELAY_URL" >/dev/null 2>&1
if [ $((i % 50)) -eq 0 ]; then
echo " Posted $i/$NUM_EVENTS events..."
fi
done
echo "✓ Posted $NUM_EVENTS test events"
echo ""
fi
# Wait for events to be stored
echo "Waiting 2 seconds for events to be stored..."
sleep 2
echo ""
# Test 1: Retrieve 500 events using nak req
echo "=========================================="
echo "TEST 1: Retrieve $NUM_EVENTS events"
echo "=========================================="
echo "Sending REQ with limit=$NUM_EVENTS..."
echo ""
START_TIME=$(date +%s%N)
# Use nak req to retrieve events (properly handles subscription protocol)
RESPONSE=$(nak req -k 1 -l $NUM_EVENTS "$RELAY_URL" 2>/dev/null)
END_TIME=$(date +%s%N)
ELAPSED_MS=$(( (END_TIME - START_TIME) / 1000000 ))
# Count events received (each line is one event)
EVENT_COUNT=$(echo "$RESPONSE" | grep -c '^{')
echo "Results:"
echo " Time elapsed: ${ELAPSED_MS}ms"
echo " Events received: $EVENT_COUNT"
echo ""
if [ $EVENT_COUNT -ge $((NUM_EVENTS - 10)) ]; then
echo "✓ TEST 1 PASSED: Retrieved $EVENT_COUNT events in ${ELAPSED_MS}ms"
if [ $ELAPSED_MS -lt 100 ]; then
echo " ⚡ EXCELLENT: <100ms for $EVENT_COUNT events!"
elif [ $ELAPSED_MS -lt 500 ]; then
echo " ✓ GOOD: <500ms for $EVENT_COUNT events"
elif [ $ELAPSED_MS -lt 2000 ]; then
echo " ⚠ ACCEPTABLE: <2s for $EVENT_COUNT events"
else
echo " ⚠ SLOW: ${ELAPSED_MS}ms for $EVENT_COUNT events (expected <100ms)"
fi
else
echo "✗ TEST 1 FAILED: Only retrieved $EVENT_COUNT events (expected ~$NUM_EVENTS)"
fi
echo ""
# Test 2: Retrieve events by author (use first test key's pubkey)
echo "=========================================="
echo "TEST 2: Retrieve events by author"
echo "=========================================="
echo "Sending REQ with authors filter..."
echo ""
# Get pubkey from first secret key
TEST_PUBKEY=$(nak key public ${SECRET_KEYS[0]})
START_TIME=$(date +%s%N)
RESPONSE=$(nak req -k 1 -a "$TEST_PUBKEY" -l $NUM_EVENTS "$RELAY_URL" 2>/dev/null)
END_TIME=$(date +%s%N)
ELAPSED_MS=$(( (END_TIME - START_TIME) / 1000000 ))
EVENT_COUNT=$(echo "$RESPONSE" | grep -c '^{')
echo "Results:"
echo " Time elapsed: ${ELAPSED_MS}ms"
echo " Events received: $EVENT_COUNT"
echo " (Note: Only events from first test key, ~1/3 of total)"
echo ""
if [ $EVENT_COUNT -ge $((NUM_EVENTS / 3 - 20)) ]; then
echo "✓ TEST 2 PASSED: Retrieved $EVENT_COUNT events in ${ELAPSED_MS}ms"
else
echo "⚠ TEST 2 WARNING: Only retrieved $EVENT_COUNT events (expected ~$((NUM_EVENTS / 3)))"
fi
echo ""
# Test 3: Retrieve events with time filter
echo "=========================================="
echo "TEST 3: Retrieve events with time filter"
echo "=========================================="
echo "Sending REQ with since filter (last hour)..."
echo ""
SINCE_TIME=$(($(date +%s) - 3600))
START_TIME=$(date +%s%N)
RESPONSE=$(nak req -k 1 --since "$SINCE_TIME" -l $NUM_EVENTS "$RELAY_URL" 2>/dev/null)
END_TIME=$(date +%s%N)
ELAPSED_MS=$(( (END_TIME - START_TIME) / 1000000 ))
EVENT_COUNT=$(echo "$RESPONSE" | grep -c '^{')
echo "Results:"
echo " Time elapsed: ${ELAPSED_MS}ms"
echo " Events received: $EVENT_COUNT"
echo ""
if [ $EVENT_COUNT -ge $((NUM_EVENTS - 10)) ]; then
echo "✓ TEST 3 PASSED: Retrieved $EVENT_COUNT events in ${ELAPSED_MS}ms"
else
echo "⚠ TEST 3 WARNING: Only retrieved $EVENT_COUNT events (expected ~$NUM_EVENTS)"
fi
echo ""
# Test 4: Multiple small retrievals (simulating real-world usage)
echo "=========================================="
echo "TEST 4: Multiple small retrievals (50 events × 10 times)"
echo "=========================================="
echo "Simulating real-world client behavior..."
echo ""
TOTAL_TIME=0
TOTAL_EVENTS=0
for i in $(seq 1 10); do
START_TIME=$(date +%s%N)
RESPONSE=$(nak req -k 1 -l 50 "$RELAY_URL" 2>/dev/null)
END_TIME=$(date +%s%N)
ELAPSED_MS=$(( (END_TIME - START_TIME) / 1000000 ))
TOTAL_TIME=$((TOTAL_TIME + ELAPSED_MS))
EVENT_COUNT=$(echo "$RESPONSE" | grep -c '^{')
TOTAL_EVENTS=$((TOTAL_EVENTS + EVENT_COUNT))
echo " Request $i: ${ELAPSED_MS}ms ($EVENT_COUNT events)"
done
AVG_TIME=$((TOTAL_TIME / 10))
echo ""
echo "Results:"
echo " Total time: ${TOTAL_TIME}ms"
echo " Total events: $TOTAL_EVENTS"
echo " Average time per request: ${AVG_TIME}ms"
echo ""
if [ $AVG_TIME -lt 50 ]; then
echo "✓ TEST 4 PASSED: Average retrieval time ${AVG_TIME}ms (excellent)"
elif [ $AVG_TIME -lt 200 ]; then
echo "✓ TEST 4 PASSED: Average retrieval time ${AVG_TIME}ms (good)"
else
echo "⚠ TEST 4 WARNING: Average retrieval time ${AVG_TIME}ms (slow)"
fi
echo ""
# Performance Summary
echo "=========================================="
echo "PERFORMANCE SUMMARY"
echo "=========================================="
echo ""
echo "Expected performance with event_json optimization:"
echo " - 366 events: <10ms (previously 18 seconds)"
echo " - 500 events: <15ms"
echo " - Per-event overhead: ~0.02ms (vs 50ms before)"
echo ""
if [ -n "$DB_FILE" ]; then
FINAL_COUNT=$(sqlite3 "$DB_FILE" "SELECT COUNT(*) FROM events WHERE kind=1;" 2>/dev/null || echo "0")
echo "Final database stats:"
echo " Total kind 1 events: $FINAL_COUNT"
echo " Database file: $DB_FILE"
echo ""
fi
echo "Check relay logs for [QUERY] entries to see actual query times:"
echo " journalctl -u c-relay -n 100 | grep QUERY"
echo ""
echo "=========================================="
echo "Test Complete"
echo "=========================================="
+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 ===