Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b82f7eaaf3 | ||
|
|
0dc5b75d7c | ||
|
|
e94b1a81e3 | ||
|
|
1adabdbc4e | ||
|
|
81d44c3d8c | ||
|
|
7acc0bdd90 | ||
|
|
c4ef71d673 | ||
|
|
086d2af56c | ||
|
|
18a7deec54 | ||
|
|
c794370a3f | ||
|
|
2ed4b96058 | ||
|
|
c0051b22be | ||
|
|
4cc2d2376e | ||
|
|
30dc4bf67d | ||
|
|
a1928cc5d7 | ||
|
|
7bf0757b1f | ||
|
|
11b0a88cdd |
@@ -11,3 +11,4 @@ copy_executable_local.sh
|
||||
nostr_login_lite/
|
||||
style_guide/
|
||||
nostr-tools
|
||||
.test_keys
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
+55
-7
@@ -67,6 +67,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 +262,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 +285,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">
|
||||
|
||||
</div>
|
||||
<div id="whitelistWarning" class="warning-box" style="display: none;">
|
||||
<strong>⚠️ WARNING:</strong> Adding whitelist rules changes relay behavior to whitelist-only
|
||||
@@ -296,10 +303,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>
|
||||
|
||||
|
||||
|
||||
+425
-21
@@ -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,115 @@ 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 ===');
|
||||
|
||||
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
|
||||
pendingAdminVerification = true;
|
||||
|
||||
// Show a loading indicator while verifying
|
||||
showAdminVerificationLoading();
|
||||
|
||||
// Send system_status command to verify admin access
|
||||
try {
|
||||
await sendAdminCommand(['system_command', 'system_status']);
|
||||
console.log('Admin verification command sent');
|
||||
} catch (error) {
|
||||
console.error('Failed to send admin verification command:', error);
|
||||
// Continue with timeout - the command might have been queued
|
||||
}
|
||||
|
||||
// Set timeout for admin verification (5 seconds)
|
||||
adminVerificationTimeout = setTimeout(() => {
|
||||
if (pendingAdminVerification) {
|
||||
console.log('⛔ Admin verification timeout - user is not admin');
|
||||
pendingAdminVerification = false;
|
||||
isAdminVerified = false;
|
||||
hideAdminVerificationLoading();
|
||||
showAccessDeniedOverlay();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// 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>Please wait while we verify your admin privileges.</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 +624,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 +669,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
|
||||
}
|
||||
@@ -1717,6 +1847,31 @@ function handleSystemCommandResponse(responseData) {
|
||||
console.log('Command:', responseData.command);
|
||||
console.log('Status:', responseData.status);
|
||||
|
||||
// Handle admin verification via system_status response
|
||||
if (responseData.command === 'system_status' && pendingAdminVerification) {
|
||||
console.log('✅ Admin verification successful - received system_status response');
|
||||
|
||||
// Clear the timeout
|
||||
if (adminVerificationTimeout) {
|
||||
clearTimeout(adminVerificationTimeout);
|
||||
adminVerificationTimeout = null;
|
||||
}
|
||||
|
||||
pendingAdminVerification = false;
|
||||
isAdminVerified = true;
|
||||
|
||||
// Hide loading overlay
|
||||
hideAdminVerificationLoading();
|
||||
|
||||
// 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 +1896,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 +2574,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 +2591,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 +2616,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)
|
||||
@@ -4324,6 +4704,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 +4720,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 +5108,7 @@ function switchPage(pageName) {
|
||||
'subscriptionDetailsSection',
|
||||
'div_config',
|
||||
'authRulesSection',
|
||||
'wotSection',
|
||||
'relayEventsSection',
|
||||
'nip17DMSection',
|
||||
'sqlQuerySection'
|
||||
@@ -4729,22 +5121,34 @@ function switchPage(pageName) {
|
||||
}
|
||||
});
|
||||
|
||||
// Show selected section
|
||||
// Show selected section(s)
|
||||
const pageMap = {
|
||||
'statistics': 'databaseStatisticsSection',
|
||||
'subscriptions': 'subscriptionDetailsSection',
|
||||
'configuration': 'div_config',
|
||||
'authorization': 'authRulesSection',
|
||||
'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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+5
-12
@@ -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
|
||||
|
||||
Executable
+122
@@ -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 ""
|
||||
@@ -188,17 +188,17 @@ update_version_in_header() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Update VERSION macro
|
||||
sed -i "s/#define VERSION \".*\"/#define VERSION \"$new_version\"/" src/main.h
|
||||
# Update CRELAY_VERSION macro
|
||||
sed -i "s/#define CRELAY_VERSION \".*\"/#define CRELAY_VERSION \"$new_version\"/" src/main.h
|
||||
|
||||
# Update VERSION_MAJOR macro
|
||||
sed -i "s/#define VERSION_MAJOR [0-9]\+/#define VERSION_MAJOR $major/" src/main.h
|
||||
# Update CRELAY_VERSION_MAJOR macro
|
||||
sed -i "s/#define CRELAY_VERSION_MAJOR [0-9]\+/#define CRELAY_VERSION_MAJOR $major/" src/main.h
|
||||
|
||||
# Update VERSION_MINOR macro
|
||||
sed -i "s/#define VERSION_MINOR .*/#define VERSION_MINOR $minor/" src/main.h
|
||||
# Update CRELAY_VERSION_MINOR macro
|
||||
sed -i "s/#define CRELAY_VERSION_MINOR .*/#define CRELAY_VERSION_MINOR $minor/" src/main.h
|
||||
|
||||
# Update VERSION_PATCH macro
|
||||
sed -i "s/#define VERSION_PATCH [0-9]\+/#define VERSION_PATCH $patch/" src/main.h
|
||||
# Update CRELAY_VERSION_PATCH macro
|
||||
sed -i "s/#define CRELAY_VERSION_PATCH [0-9]\+/#define CRELAY_VERSION_PATCH $patch/" src/main.h
|
||||
|
||||
print_success "Updated version in src/main.h to $new_version"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 ... │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -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.
|
||||
+4937
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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;
|
||||
@@ -2721,8 +2800,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);
|
||||
}
|
||||
|
||||
|
||||
+474
-8
@@ -951,12 +951,29 @@ static int validate_config_field(const char* key, const char* value, char* error
|
||||
}
|
||||
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;
|
||||
@@ -1718,13 +1735,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";
|
||||
}
|
||||
|
||||
@@ -2129,6 +2149,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
|
||||
// ================================
|
||||
@@ -2553,20 +2863,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, ...]
|
||||
@@ -2999,6 +3335,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();
|
||||
@@ -3266,6 +3616,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_GetObjectItem(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_GetObjectItem(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;
|
||||
@@ -4055,7 +4517,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 +4527,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";
|
||||
}
|
||||
|
||||
@@ -4348,7 +4813,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";
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -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);
|
||||
|
||||
@@ -83,11 +83,28 @@ static const struct {
|
||||
// IP-based rate limiting or access control (which would require firewall protection anyway)
|
||||
{"trust_proxy_headers", "true"},
|
||||
|
||||
// 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
|
||||
|
||||
+152
@@ -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);
|
||||
@@ -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)
|
||||
|
||||
File diff suppressed because one or more lines are too long
+406
-70
@@ -20,6 +20,7 @@
|
||||
#include "../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
#include "../nostr_core_lib/nostr_core/nip013.h" // NIP-13: Proof of Work
|
||||
#include "../nostr_core_lib/nostr_core/nip019.h" // NIP-19: bech32-encoded entities
|
||||
#include "main.h" // Version and relay metadata
|
||||
#include "config.h" // Configuration management system
|
||||
#include "sql_schema.h" // Embedded database schema
|
||||
#include "websockets.h" // WebSocket protocol implementation
|
||||
@@ -228,6 +229,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
|
||||
@@ -369,12 +429,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 {
|
||||
@@ -497,6 +566,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;
|
||||
}
|
||||
@@ -588,6 +691,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) {
|
||||
@@ -632,10 +775,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);
|
||||
@@ -654,13 +805,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);
|
||||
|
||||
@@ -688,20 +842,88 @@ 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_GetObjectItem(event, "kind");
|
||||
cJSON* pubkey_obj = cJSON_GetObjectItem(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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -763,58 +985,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_GetObjectItem(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;
|
||||
@@ -929,7 +1210,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;
|
||||
@@ -965,7 +1247,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);
|
||||
|
||||
@@ -1098,8 +1381,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);
|
||||
|
||||
@@ -1191,6 +1474,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);
|
||||
@@ -1198,42 +1485,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)) {
|
||||
@@ -1243,27 +1552,36 @@ 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);
|
||||
|
||||
char* msg_str = cJSON_Print(event_msg);
|
||||
// Build EVENT message using string concatenation (much faster than cJSON operations)
|
||||
// Format: ["EVENT","<sub_id>",<event_json>]
|
||||
size_t sub_id_len = strlen(sub_id);
|
||||
size_t event_json_len = strlen(event_json_str);
|
||||
size_t msg_len = 10 + sub_id_len + 3 + event_json_len + 1; // ["EVENT",""] + sub_id + "," + event_json + ]
|
||||
|
||||
char* msg_str = malloc(msg_len + 1);
|
||||
if (msg_str) {
|
||||
size_t msg_len = strlen(msg_str);
|
||||
snprintf(msg_str, msg_len + 1, "[\"EVENT\",\"%s\",%s]", sub_id, event_json_str);
|
||||
|
||||
// Use proper message queue system instead of direct lws_write
|
||||
if (queue_message(wsi, pss, msg_str, msg_len, LWS_WRITE_TEXT) != 0) {
|
||||
if (queue_message(wsi, pss, msg_str, strlen(msg_str), LWS_WRITE_TEXT) != 0) {
|
||||
DEBUG_ERROR("Failed to queue EVENT message for sub=%s", sub_id);
|
||||
}
|
||||
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
|
||||
@@ -1426,7 +1744,7 @@ void print_usage(const char* program_name) {
|
||||
|
||||
// Print version information
|
||||
void print_version() {
|
||||
printf("C Nostr Relay Server v1.0.0\n");
|
||||
printf("C Nostr Relay Server %s\n", CRELAY_VERSION);
|
||||
printf("Event-based configuration system\n");
|
||||
printf("Built with nostr_core_lib integration\n");
|
||||
printf("\n");
|
||||
@@ -1562,6 +1880,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");
|
||||
|
||||
|
||||
@@ -1807,6 +2129,20 @@ 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();
|
||||
|
||||
|
||||
+6
-9
@@ -10,21 +10,18 @@
|
||||
#define MAIN_H
|
||||
|
||||
// Version information (auto-updated by build system)
|
||||
#define VERSION_MAJOR 1
|
||||
#define VERSION_MINOR 1
|
||||
#define VERSION_PATCH 1
|
||||
#define VERSION "v1.1.1"
|
||||
|
||||
// Avoid VERSION_MAJOR redefinition warning from nostr_core_lib
|
||||
#undef VERSION_MAJOR
|
||||
#define VERSION_MAJOR 1
|
||||
// Using CRELAY_ prefix to avoid conflicts with nostr_core_lib VERSION macros
|
||||
#define CRELAY_VERSION_MAJOR 1
|
||||
#define CRELAY_VERSION_MINOR 2
|
||||
#define CRELAY_VERSION_PATCH 8
|
||||
#define CRELAY_VERSION "v1.2.8"
|
||||
|
||||
// Relay metadata (authoritative source for NIP-11 information)
|
||||
#define RELAY_NAME "C-Relay"
|
||||
#define RELAY_DESCRIPTION "High-performance C Nostr relay with SQLite storage"
|
||||
#define RELAY_CONTACT ""
|
||||
#define RELAY_SOFTWARE "https://git.laantungir.net/laantungir/c-relay.git"
|
||||
#define RELAY_VERSION VERSION // Use the same version as the build
|
||||
#define RELAY_VERSION CRELAY_VERSION // Use the same version as the build
|
||||
#define SUPPORTED_NIPS "1,2,4,9,11,12,13,15,16,20,22,33,40,42,50,70"
|
||||
#define LANGUAGE_TAGS ""
|
||||
#define RELAY_COUNTRIES ""
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+31
-7
@@ -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 */
|
||||
|
||||
+237
-47
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -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--;
|
||||
@@ -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,38 @@ 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);
|
||||
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 ? "..." : "");
|
||||
|
||||
// Serialize event once per subscription using pre-serialized event_json if available,
|
||||
// otherwise fall back to cJSON serialization.
|
||||
// Format: ["EVENT","<sub_id>",<event_json>]
|
||||
cJSON* event_id_obj = cJSON_GetObjectItem(event, "id");
|
||||
const char* event_json_str = NULL;
|
||||
char* event_json_allocated = NULL;
|
||||
|
||||
// Try to get pre-serialized event_json field first (fast path)
|
||||
cJSON* event_json_field = cJSON_GetObjectItem(event, "event_json");
|
||||
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;
|
||||
char* msg_str = malloc(msg_len + 1);
|
||||
if (msg_str) {
|
||||
snprintf(msg_str, msg_len + 1, "[\"EVENT\",\"%s\",%s]", current_temp->id, event_json_str);
|
||||
size_t actual_len = strlen(msg_str);
|
||||
|
||||
DEBUG_TRACE("WS_FRAME_SEND: type=EVENT sub=%s len=%zu", current_temp->id, actual_len);
|
||||
|
||||
// Queue message for proper libwebsockets pattern
|
||||
struct per_session_data* pss = (struct per_session_data*)lws_wsi_user(current_temp->wsi);
|
||||
if (queue_message(current_temp->wsi, pss, msg_str, msg_len, LWS_WRITE_TEXT) == 0) {
|
||||
// Message queued successfully
|
||||
if (queue_message(current_temp->wsi, pss, msg_str, actual_len, LWS_WRITE_TEXT) == 0) {
|
||||
broadcasts++;
|
||||
|
||||
// Update events sent counter for this subscription
|
||||
@@ -735,30 +913,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);
|
||||
}
|
||||
|
||||
free(buf);
|
||||
free(msg_str);
|
||||
}
|
||||
free(msg_str);
|
||||
}
|
||||
|
||||
cJSON_Delete(event_msg);
|
||||
if (event_json_allocated) {
|
||||
free(event_json_allocated);
|
||||
}
|
||||
current_temp = current_temp->next;
|
||||
}
|
||||
|
||||
@@ -1289,6 +1459,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,6 +1470,8 @@ 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");
|
||||
if (kinds) {
|
||||
@@ -1318,11 +1494,25 @@ 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
+167
-58
@@ -86,6 +86,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);
|
||||
@@ -130,6 +135,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) {
|
||||
@@ -391,6 +404,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));
|
||||
@@ -758,44 +776,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) {
|
||||
@@ -903,7 +935,21 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
DEBUG_TRACE("REQ message passed authentication check");
|
||||
|
||||
// Handle REQ message
|
||||
@@ -965,11 +1011,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 +1100,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
|
||||
@@ -1458,44 +1512,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) {
|
||||
@@ -1663,11 +1731,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 +1818,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);
|
||||
@@ -2429,7 +2505,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");
|
||||
@@ -2687,6 +2763,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 +2791,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 +2937,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 +2954,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 +3036,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 +3122,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
|
||||
}
|
||||
|
||||
@@ -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,11 @@ 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)
|
||||
};
|
||||
|
||||
// NIP-11 HTTP session data structure for managing buffer lifetime
|
||||
|
||||
+22
-7
@@ -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
|
||||
|
||||
|
||||
Executable
+270
@@ -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 "=========================================="
|
||||
Reference in New Issue
Block a user