Compare commits

...
19 Commits
Author SHA1 Message Date
Your Name 11aaccba9b v1.2.20 - Add IP auth failure ban system: track failures per IP, ban after threshold with exponential backoff, periodic log stats 2026-02-23 16:02:07 -04:00
Your Name bd1bbd763d v1.2.19 - Add IP auth failure ban system: track failures per IP, ban after threshold with exponential backoff, periodic log stats 2026-02-23 16:00:53 -04:00
Your Name 2bd7aa5a10 v1.2.18 - Proactive auth timeout via lws_set_timeout: close idle unauthenticated connections after nip42_auth_timeout_sec even without REQ 2026-02-23 15:37:45 -04:00
Your Name 361912ec85 v1.2.17 - Add nip42_auth_timeout_sec (default 10s): close unauthenticated connections after timeout to prevent connection accumulation 2026-02-23 15:22:18 -04:00
Your Name 0de491382e v1.2.16 - Fix auth rules UI: change label/placeholder/errors from nsec to npub (public key, not private key) 2026-02-23 14:36:19 -04:00
Your Name 3148bbbee7 v1.2.15 - Admin verification: show 'Waiting for response' with status updates, 60s timeout, no premature access denied 2026-02-23 14:26:02 -04:00
Your Name 3965ba04d8 v1.2.14 - Handle late admin verification response: grant access even if timeout already fired and access-denied overlay was shown 2026-02-23 14:24:55 -04:00
Your Name b96af938bd v1.2.13 - Add WoT status to NIP-11 response: restricted_writes, auth_required, and web_of_trust object when WoT enabled 2026-02-23 14:22:08 -04:00
Your Name 89c8248013 v1.2.12 - Increase admin verification timeout from 5s to 30s; continue waiting after publish timeout under heavy load 2026-02-23 14:16:56 -04:00
Your Name d8f477c6cf v1.2.11 - Early duplicate check before secp256k1 signature verification — skip crypto for events already in DB 2026-02-23 14:01:23 -04:00
Your Name 040eeadb13 v1.2.10 - Zero-copy message queue: eliminate memcpy in broadcast and REQ paths via queue_message_take_ownership() 2026-02-23 13:45:56 -04:00
Your Name 83f8b0ab88 v1.2.9 - Replace cJSON_GetObjectItem with CaseSensitive variant (178 calls) — eliminates 14% CPU from tolower+linear scan in hot path 2026-02-23 13:25:50 -04:00
Your Name b82f7eaaf3 v1.2.8 - Add debug build support: _debug suffix in build_static.sh, deploy_lt_debug.sh with perf profiling workflow 2026-02-23 13:07:53 -04:00
Your Name 0dc5b75d7c v1.2.7 - Add configurable SQLite mmap_size (256MB) and cache_size (64MB) PRAGMAs for ~20% CPU reduction from DB read overhead 2026-02-23 09:48:01 -04:00
Your Name e94b1a81e3 v1.2.6 - Add MAX_MESSAGE_QUEUE_SIZE=500 limit to prevent OOM from unbounded write queue; fix wasted buf allocation in broadcast 2026-02-23 09:12:09 -04:00
Your Name 1adabdbc4e v1.2.5 - Add nip17_admin_enabled config toggle (default off) to prevent OOM from NIP-17 gift wrap decryption flood 2026-02-23 08:58:10 -04:00
Your Name 81d44c3d8c v1.2.4 - Add more characters to valid subscription characters 2026-02-11 05:56:09 -04:00
Your Name 7acc0bdd90 v1.2.3 - Handle rate limiting properly on kind 99999 request 2026-02-09 07:49:43 -04:00
Your Name c4ef71d673 v1.2.2 - Add + to allowed subscription characters 2026-02-07 13:32:00 -04:00
36 changed files with 13663 additions and 390 deletions
+1
View File
@@ -11,3 +11,4 @@ copy_executable_local.sh
nostr_login_lite/
style_guide/
nostr-tools
.test_keys
+1 -1
View File
@@ -121,7 +121,7 @@ RUN if [ "$DEBUG_BUILD" = "true" ]; then \
-Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \
src/main.c src/config.c src/dm_admin.c src/request_validator.c \
src/nip009.c src/nip011.c src/nip013.c src/nip040.c src/nip042.c \
src/websockets.c src/subscriptions.c src/api.c src/embedded_web_content.c \
src/websockets.c src/subscriptions.c src/api.c src/embedded_web_content.c src/ip_ban.c \
-o /build/c_relay_static \
c_utils_lib/libc_utils.a \
nostr_core_lib/libnostr_core_x64.a \
+1 -1
View File
@@ -9,7 +9,7 @@ LIBS = -lsqlite3 -lwebsockets -lz -ldl -lpthread -lm -L/usr/local/lib -lsecp256k
BUILD_DIR = build
# Source files
MAIN_SRC = src/main.c src/config.c src/dm_admin.c src/request_validator.c src/nip009.c src/nip011.c src/nip013.c src/nip040.c src/nip042.c src/websockets.c src/subscriptions.c src/api.c src/embedded_web_content.c
MAIN_SRC = src/main.c src/config.c src/dm_admin.c src/request_validator.c src/nip009.c src/nip011.c src/nip013.c src/nip040.c src/nip042.c src/websockets.c src/subscriptions.c src/api.c src/embedded_web_content.c src/ip_ban.c
NOSTR_CORE_LIB = nostr_core_lib/libnostr_core_x64.a
C_UTILS_LIB = c_utils_lib/libc_utils.a
+178
View File
@@ -1291,6 +1291,184 @@ body.dark-mode .sql-results-table tbody tr:nth-child(even) {
/* background-color: var(--secondary-color); */
}
/* ================================
WEB OF TRUST (WoT) STYLES
================================ */
.wot-status-row, .wot-stats-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
font-size: 14px;
}
.wot-indicator {
padding: 2px 10px;
border-radius: 4px;
font-weight: bold;
font-size: 12px;
}
.wot-indicator.wot-found { background: #28a745; color: white; }
.wot-indicator.wot-missing { background: #dc3545; color: white; }
.wot-indicator.wot-unknown { background: #6c757d; color: white; }
.wot-level-selector { padding: 10px 0; }
.wot-level-selector label { display: block; margin-bottom: 5px; font-weight: bold; }
.wot-level-btn { min-width: 100px; }
.wot-level-btn.active {
background: var(--accent-color, #ff0000);
color: white;
border-color: var(--accent-color, #ff0000);
}
.wot-level-description {
font-size: 12px;
color: var(--primary-color);
margin-top: 5px;
font-style: italic;
opacity: 0.8;
}
/* Dark mode adjustments for WoT */
body.dark-mode .wot-indicator.wot-found { background: #28a745; }
body.dark-mode .wot-indicator.wot-missing { background: #dc3545; }
body.dark-mode .wot-indicator.wot-unknown { background: #6c757d; }
/* ================================
ADMIN ACCESS GATE STYLES
================================ */
/* Access Denied Overlay */
.access-denied-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
z-index: 9999;
display: none;
justify-content: center;
align-items: center;
}
.access-denied-content {
background: var(--card-bg);
border: 2px solid #dc3545;
border-radius: 12px;
padding: 40px 60px;
text-align: center;
max-width: 500px;
box-shadow: 0 10px 40px rgba(220, 53, 69, 0.3);
}
.access-denied-icon {
font-size: 64px;
margin-bottom: 20px;
}
.access-denied-content h2 {
color: #dc3545;
font-size: 32px;
margin-bottom: 20px;
letter-spacing: 2px;
}
.access-denied-message {
font-size: 16px;
color: var(--primary-color);
margin-bottom: 10px;
line-height: 1.5;
}
.access-denied-submessage {
font-size: 14px;
color: var(--muted-color);
margin-bottom: 30px;
font-style: italic;
}
.access-denied-logout-btn {
background: #dc3545;
color: white;
border: none;
padding: 12px 40px;
font-size: 16px;
font-weight: bold;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
}
.access-denied-logout-btn:hover {
background: #c82333;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4);
}
/* Admin Verification Loading Overlay */
.admin-verification-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 9998;
display: none;
justify-content: center;
align-items: center;
}
.admin-verification-content {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 40px 60px;
text-align: center;
max-width: 450px;
}
.admin-verification-content h3 {
color: var(--accent-color);
font-size: 20px;
margin-bottom: 15px;
}
.admin-verification-content p {
color: var(--muted-color);
font-size: 14px;
margin-top: 15px;
}
/* Spinner Animation */
.spinner {
width: 50px;
height: 50px;
border: 4px solid var(--border-color);
border-top: 4px solid var(--accent-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Dark mode adjustments */
body.dark-mode .access-denied-content {
background: var(--card-bg);
}
body.dark-mode .admin-verification-content {
background: var(--card-bg);
}
.subscription-detail-row:hover {
background-color: var(--muted-color);
}
+57 -9
View File
@@ -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">
<label for="authRulePubkey">Public Key (npub or hex):</label>
<input type="text" id="authRulePubkey" placeholder="npub1... or 64-character hex pubkey">
</div>
<div id="whitelistWarning" class="warning-box" style="display: none;">
<strong>⚠️ WARNING:</strong> Adding whitelist rules changes relay behavior to whitelist-only
@@ -296,10 +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>
+427 -20
View File
@@ -39,6 +39,11 @@ let countdownSeconds = 10;
let currentPage = 'statistics'; // Default page
let sideNavOpen = false;
// Admin verification state
let isAdminVerified = false;
let adminVerificationTimeout = null;
let pendingAdminVerification = false;
// SQL Query state
let pendingSqlQueries = new Map();
@@ -306,7 +311,10 @@ async function restoreAuthenticationState(pubkey) {
loadUserProfile();
// Automatically set up relay connection (but don't show admin sections yet)
await setupAutomaticRelayConnection();
await setupAutomaticRelayConnection(false);
// Verify admin access before showing admin sections
verifyAdminAccess();
console.log('✅ Authentication state restored successfully');
}
@@ -422,8 +430,8 @@ async function initializeApp() {
if (wasAlreadyLoggedIn) {
console.log('User was already logged in, showing profile in header');
showProfileInHeader();
// Show admin sections since user is already authenticated and relay is connected
updateAdminSectionsVisibility();
// Admin verification will be triggered by restoreAuthenticationState
// which calls verifyAdminAccess() after setting up relay connection
} else {
console.log('No existing authentication found, showing login modal');
showLoginModal();
@@ -453,11 +461,11 @@ function handleAuthEvent(event) {
showProfileInHeader();
loadUserProfile();
// Automatically set up relay connection and show admin sections
setupAutomaticRelayConnection(true);
// Automatically set up relay connection
setupAutomaticRelayConnection(false); // Don't show sections yet - wait for admin verification
// Auto-enable monitoring when admin logs in
autoEnableMonitoring();
// Verify admin access before showing admin sections
verifyAdminAccess();
} else if (error) {
console.log(`Authentication error: ${error}`);
@@ -471,11 +479,25 @@ function handleLogoutEvent() {
userPubkey = null;
isLoggedIn = false;
currentConfig = null;
isAdminVerified = false;
// Reset relay connection state
isRelayConnected = false;
relayPubkey = null;
// Clear any pending admin verification
if (adminVerificationTimeout) {
clearTimeout(adminVerificationTimeout);
adminVerificationTimeout = null;
}
pendingAdminVerification = false;
// Hide access denied overlay if showing
hideAccessDeniedOverlay();
// Hide admin verification loading if showing
hideAdminVerificationLoading();
// Reset UI - hide profile and show login modal
hideProfileFromHeader();
showLoginModal();
@@ -486,12 +508,127 @@ 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.
// Under heavy relay load the publish OK may time out even though the relay
// received the event — so we catch the error and still wait for the response.
try {
await sendAdminCommand(['system_command', 'system_status']);
console.log('Admin verification command sent');
} catch (error) {
console.error('Failed to send admin verification command:', error);
// Under heavy load the relay may not send OK in time but still processes
// the event. Continue waiting for the response subscription to fire.
console.log('Continuing to wait for admin response despite publish error...');
}
// Update status message after 5 seconds to indicate relay is under load
setTimeout(() => {
const statusEl = document.getElementById('admin-verify-status');
if (statusEl && pendingAdminVerification) {
statusEl.textContent = 'Relay is under heavy load — still waiting for response...';
}
}, 5000);
// Final timeout: 60 seconds — if no response by then, deny access
adminVerificationTimeout = setTimeout(() => {
if (pendingAdminVerification) {
console.log('⛔ Admin verification timeout - user is not admin');
pendingAdminVerification = false;
isAdminVerified = false;
hideAdminVerificationLoading();
showAccessDeniedOverlay();
}
}, 60000);
}
// Show loading indicator while verifying admin access
function showAdminVerificationLoading() {
// Create loading overlay if it doesn't exist
let loadingOverlay = document.getElementById('admin-verification-loading');
if (!loadingOverlay) {
loadingOverlay = document.createElement('div');
loadingOverlay.id = 'admin-verification-loading';
loadingOverlay.className = 'admin-verification-overlay';
loadingOverlay.innerHTML = `
<div class="admin-verification-content">
<div class="spinner"></div>
<h3>Verifying Administrator Access...</h3>
<p id="admin-verify-status">Waiting for response from relay...</p>
</div>
`;
document.body.appendChild(loadingOverlay);
}
loadingOverlay.style.display = 'flex';
}
// Hide admin verification loading overlay
function hideAdminVerificationLoading() {
const loadingOverlay = document.getElementById('admin-verification-loading');
if (loadingOverlay) {
loadingOverlay.style.display = 'none';
}
}
// Show access denied overlay for non-admin users
function showAccessDeniedOverlay() {
const accessDeniedOverlay = document.getElementById('access-denied-overlay');
if (accessDeniedOverlay) {
accessDeniedOverlay.style.display = 'flex';
}
// Also hide any admin sections that might be showing
hideAllAdminSections();
}
// Hide access denied overlay
function hideAccessDeniedOverlay() {
const accessDeniedOverlay = document.getElementById('access-denied-overlay');
if (accessDeniedOverlay) {
accessDeniedOverlay.style.display = 'none';
}
}
// Hide all admin sections
function hideAllAdminSections() {
const sections = [
'databaseStatisticsSection',
'subscriptionDetailsSection',
'div_config',
'authRulesSection',
'wotSection',
'nip17DMSection',
'sqlQuerySection'
];
sections.forEach(sectionId => {
const section = document.getElementById(sectionId);
if (section) {
section.style.display = 'none';
}
});
}
// Update visibility of admin sections based on login, relay connection, and admin verification status
function updateAdminSectionsVisibility() {
const shouldShow = isLoggedIn && isRelayConnected;
const shouldShow = isLoggedIn && isRelayConnected && isAdminVerified;
// If logged in and connected, show the current page, otherwise hide all sections
// If logged in, connected, and admin verified, show the current page
if (shouldShow) {
// Show the current page
switchPage(currentPage);
@@ -499,12 +636,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 +681,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 +1859,33 @@ function handleSystemCommandResponse(responseData) {
console.log('Command:', responseData.command);
console.log('Status:', responseData.status);
// Handle admin verification via system_status response.
// Also handle late responses that arrive after the timeout fired (relay was under load).
if (responseData.command === 'system_status' && (pendingAdminVerification || (isLoggedIn && !isAdminVerified))) {
console.log('✅ Admin verification successful - received system_status response');
// Clear the timeout if still pending
if (adminVerificationTimeout) {
clearTimeout(adminVerificationTimeout);
adminVerificationTimeout = null;
}
pendingAdminVerification = false;
isAdminVerified = true;
// Hide loading overlay and access denied overlay (in case timeout already showed it)
hideAdminVerificationLoading();
hideAccessDeniedOverlay();
// Show admin sections now that we're verified
updateAdminSectionsVisibility();
// Auto-enable monitoring when admin logs in
autoEnableMonitoring();
log('Administrator access verified', 'INFO');
}
// Handle delete auth rule responses
if (responseData.command === 'delete_auth_rule') {
if (responseData.status === 'success') {
@@ -1741,11 +1910,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 +2588,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 +2605,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 +2630,11 @@ function hideAuthRulesSection() {
editingAuthRule = null;
log('Auth rules section hidden', 'INFO');
}
// Also hide WoT section
if (wotSection) {
wotSection.style.display = 'none';
log('WoT section hidden', 'INFO');
}
}
// Update auth rules status indicator (removed - no status element)
@@ -2774,7 +3168,7 @@ function addBlacklistRule() {
// Convert nsec or npub to hex if needed
const hexPubkey = nsecToHex(inputValue);
if (!hexPubkey) {
log('Invalid pubkey format. Please enter nsec1..., npub1..., or 64-character hex', 'ERROR');
log('Invalid public key format. Please enter npub1... or 64-character hex pubkey', 'ERROR');
return;
}
@@ -2826,7 +3220,7 @@ function addWhitelistRule() {
// Convert nsec or npub to hex if needed
const hexPubkey = nsecToHex(inputValue);
if (!hexPubkey) {
log('Invalid pubkey format. Please enter nsec1..., npub1..., or 64-character hex', 'ERROR');
log('Invalid public key format. Please enter npub1... or 64-character hex pubkey', 'ERROR');
return;
}
@@ -4728,6 +5122,7 @@ function switchPage(pageName) {
'subscriptionDetailsSection',
'div_config',
'authRulesSection',
'wotSection',
'relayEventsSection',
'nip17DMSection',
'sqlQuerySection'
@@ -4740,22 +5135,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';
}
}
}
+5
View File
@@ -77,6 +77,11 @@ case "$ARCH" in
;;
esac
# Append _debug suffix to output name for debug builds so production binary is never overwritten
if [ "$DEBUG_BUILD" = true ]; then
OUTPUT_NAME="${OUTPUT_NAME}_debug"
fi
echo "Building for platform: $PLATFORM"
echo "Output binary: $OUTPUT_NAME"
echo ""
+9970
View File
File diff suppressed because it is too large Load Diff
+5 -12
View File
@@ -1,19 +1,12 @@
#!/bin/bash
# Restart the service
sudo systemctl stop c-relay.service
# Copy the binary to the deployment location
cp build/c_relay_x86 ~/Storage/c_relay/crelay
# Copy the service file to systemd (use the main service file)
sudo cp systemd/c-relay.service /etc/systemd/system/c-relay-local.service
# Reload systemd daemon to pick up the new service
sudo systemctl daemon-reload
# Enable the service (if not already enabled)
sudo systemctl enable c-relay-local.service
cp build/c_relay_static_x86_64 ~/Storage/c_relay/crelay
# Restart the service
sudo systemctl restart c-relay-local.service
sudo systemctl restart c-relay.service
# Show service status
sudo systemctl status c-relay-local.service --no-pager -l
sudo systemctl status c-relay.service --no-pager -l
+122
View File
@@ -0,0 +1,122 @@
#!/bin/bash
# C-Relay Debug Binary Deployment Script
# Deploys build/c_relay_static_x86_64_debug to server for CPU profiling
#
# Usage:
# ./deploy_lt_debug.sh -- deploy debug binary and restart
# ./deploy_lt_debug.sh --profile -- deploy, then run perf and fetch results
#
# After deploying, profile with:
# sudo perf record -g -p $(pgrep c_relay) -- sleep 30
# sudo perf report --stdio --sort=symbol --no-children -n 2>/dev/null | head -80
#
# Restore production binary:
# ./deploy_lt.sh
set -e
LOCAL_DEBUG_BINARY="build/c_relay_static_x86_64_debug"
REMOTE_BINARY_PATH="/usr/local/bin/c_relay/c_relay"
REMOTE_PROD_BACKUP="/usr/local/bin/c_relay/c_relay.production"
SERVICE_NAME="c-relay"
REMOTE_HOST="ubuntu@laantungir.com"
# Check debug binary exists
if [ ! -f "$LOCAL_DEBUG_BINARY" ]; then
echo "ERROR: Debug binary not found: $LOCAL_DEBUG_BINARY"
echo ""
echo "Build it first with:"
echo " ./build_static.sh --debug"
echo ""
exit 1
fi
echo "=========================================="
echo "C-Relay Debug Deployment"
echo "=========================================="
echo "Binary: $LOCAL_DEBUG_BINARY ($(du -h "$LOCAL_DEBUG_BINARY" | cut -f1))"
echo "Target: $REMOTE_HOST:$REMOTE_BINARY_PATH"
echo ""
echo "WARNING: Debug binary has symbols and is larger than production."
echo " It is safe to run but should not be left deployed long-term."
echo ""
# Backup production binary (only if not already backed up)
echo "Backing up production binary..."
ssh "$REMOTE_HOST" "
if [ ! -f '$REMOTE_PROD_BACKUP' ]; then
sudo cp '$REMOTE_BINARY_PATH' '$REMOTE_PROD_BACKUP'
echo 'Production binary backed up to $REMOTE_PROD_BACKUP'
else
echo 'Production backup already exists, skipping'
fi
"
# Upload debug binary
echo "Uploading debug binary..."
scp "$LOCAL_DEBUG_BINARY" "$REMOTE_HOST:/tmp/c_relay_debug.tmp"
# Install debug binary
echo "Installing debug binary..."
ssh "$REMOTE_HOST" "
sudo mv '/tmp/c_relay_debug.tmp' '$REMOTE_BINARY_PATH'
sudo chown c-relay:c-relay '$REMOTE_BINARY_PATH'
sudo chmod +x '$REMOTE_BINARY_PATH'
"
# Restart service
echo "Restarting c-relay service..."
ssh "$REMOTE_HOST" "sudo systemctl daemon-reload && sudo systemctl restart '$SERVICE_NAME'"
echo ""
echo "✓ Debug binary deployed and service restarted"
echo ""
# If --profile flag, run perf automatically
if [ "$1" = "--profile" ]; then
echo "=========================================="
echo "Running perf profile (30 seconds)..."
echo "=========================================="
echo ""
# Wait for relay to start
sleep 3
ssh "$REMOTE_HOST" "
PID=\$(pgrep c_relay)
if [ -z \"\$PID\" ]; then
echo 'ERROR: c_relay not running'
exit 1
fi
echo \"Profiling PID \$PID for 30 seconds...\"
sudo perf record -g -p \$PID -- sleep 30
echo ''
echo '=== TOP FUNCTIONS BY CPU ==='
sudo perf report --stdio --sort=symbol --no-children -n 2>/dev/null | head -60
"
else
echo "=========================================="
echo "Next Steps: Profile the relay"
echo "=========================================="
echo ""
echo "SSH to server and run:"
echo " sudo perf record -g -p \$(pgrep c_relay) -- sleep 30"
echo " sudo perf report --stdio --sort=symbol --no-children -n 2>/dev/null | head -60"
echo ""
echo "Or run with --profile to do it automatically:"
echo " ./deploy_lt_debug.sh --profile"
fi
echo ""
echo "=========================================="
echo "Restore Production Binary When Done"
echo "=========================================="
echo ""
echo "Run this to restore the production binary:"
echo " ./deploy_lt.sh"
echo ""
echo "Or manually on the server:"
echo " sudo cp '$REMOTE_PROD_BACKUP' '$REMOTE_BINARY_PATH'"
echo " sudo systemctl restart $SERVICE_NAME"
echo ""
+20 -2
View File
@@ -70,6 +70,24 @@ while [[ $# -gt 0 ]]; do
;;
--test-keys|-t)
USE_TEST_KEYS=true
# Read keys from .test_keys file
if [ -f ".test_keys" ]; then
echo "Reading test keys from .test_keys file..."
# Source the file to get the variables
source .test_keys
# Remove any single quotes from the values
# Note: -a flag expects ADMIN_PUBKEY (public key), not ADMIN_PRIVKEY
ADMIN_KEY=$(echo "$ADMIN_PUBKEY" | tr -d "'")
RELAY_KEY=$(echo "$SERVER_PRIVKEY" | tr -d "'")
echo "Using admin pubkey from .test_keys: ${ADMIN_KEY:0:16}..."
echo "Using relay privkey from .test_keys: ${RELAY_KEY:0:16}..."
else
echo "ERROR: .test_keys file not found"
echo "Please create a .test_keys file with the following format:"
echo " ADMIN_PUBKEY='your_admin_public_key_hex'"
echo " SERVER_PRIVKEY='your_relay_private_key_hex'"
exit 1
fi
shift
;;
--debug-level=*)
@@ -336,8 +354,8 @@ fi
cd build
# Start relay in background and capture its PID
if [ "$USE_TEST_KEYS" = true ]; then
echo "Using deterministic test keys for development..."
./$(basename $BINARY_PATH) -a 6a04ab98d9e4774ad806e302dddeb63bea16b5cb5f223ee77478e861bb583eb3 -r 1111111111111111111111111111111111111111111111111111111111111111 --debug-level=$DEBUG_LEVEL --strict-port > ../relay.log 2>&1 &
echo "Using test keys from .test_keys file..."
./$(basename $BINARY_PATH) -a "$ADMIN_KEY" -r "$RELAY_KEY" --debug-level=$DEBUG_LEVEL --strict-port > ../relay.log 2>&1 &
elif [ -n "$RELAY_ARGS" ]; then
echo "Starting relay with custom configuration..."
./$(basename $BINARY_PATH) $RELAY_ARGS --debug-level=$DEBUG_LEVEL --strict-port > ../relay.log 2>&1 &
+312
View File
@@ -0,0 +1,312 @@
# Event Tags Denormalization Plan
## Problem Statement
The relay is at 99% CPU with 1,178 WebSocket connections and ~120 new REQ subscriptions per minute. The root cause is that every tag-filtered REQ query (e.g., `#g`, `#e`, `#p`) uses `json_each(json(tags))` with `json_extract()` — a correlated subquery that parses the JSON tags column for every candidate row. With geohash-based subscriptions from a location app generating constant connection churn, this creates unsustainable CPU load.
### Evidence
- `EXPLAIN QUERY PLAN` shows: `CORRELATED SCALAR SUBQUERY → SCAN json_each VIRTUAL TABLE`
- 8,077 subscription creates/hour but only 442 active at any time (connection churn)
- Only 56 events stored/hour — the load is entirely from **read queries**, not writes
- 14,819 events match the kind filter; each query parses JSON tags on candidate rows
## Solution: Denormalized `event_tags` Table
Replace `json_each()` queries with indexed lookups on a separate `event_tags` table. This is the standard approach used by strfry, nostream, and other production Nostr relays.
### Performance Impact
| Metric | Before | After |
|--------|--------|-------|
| Tag query type | `json_each()` correlated subquery | Indexed JOIN/subquery |
| Complexity per query | O(rows × tags_per_event) JSON parsing | O(log n) B-tree lookup |
| Expected CPU reduction | 99% | <10% for same traffic |
## Files to Modify
| File | Changes |
|------|---------|
| `src/sql_schema.h` | Add `event_tags` table, indexes, cascade triggers; bump to schema v12 |
| `src/main.c` | Add `store_event_tags()`, update `handle_req_message()` tag filter SQL, move config reads out of row loop, add startup tag population |
| `src/nip009.c` | Add `DELETE FROM event_tags` alongside event deletions |
| `src/dm_admin.c` | Downgrade NIP-17 decryption failure log level |
| `src/websockets.c` | Downgrade NIP-17 error log to DEBUG at lines 780 and 1488 |
## Detailed Changes
### 1. Schema: `src/sql_schema.h`
Add the `event_tags` table after the events table definition. Bump schema version to 12.
```sql
-- Denormalized event tags for fast indexed lookups
-- Replaces json_each(json(tags)) queries which cause full JSON parsing per row
CREATE TABLE event_tags (
event_id TEXT NOT NULL,
tag_name TEXT NOT NULL,
tag_value TEXT NOT NULL,
tag_index INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
);
-- Primary lookup index: find events by tag name + value
CREATE INDEX idx_event_tags_lookup ON event_tags(tag_name, tag_value);
-- Reverse lookup: find all tags for an event (for cleanup)
CREATE INDEX idx_event_tags_event ON event_tags(event_id);
-- Composite index for common query pattern: tag + kind (via join)
CREATE INDEX idx_event_tags_value_name ON event_tags(tag_value, tag_name);
```
**Key design decisions:**
- `ON DELETE CASCADE` handles cleanup when events are deleted (NIP-09, replaceable events)
- `tag_index` stores the position within the tags array (useful for ordered tag access)
- Only the first two elements of each tag are indexed (`tag_name` = `$[0]`, `tag_value` = `$[1]`) — this covers all standard Nostr tag filters (`#e`, `#p`, `#t`, `#g`, `#d`, etc.)
- `PRAGMA foreign_keys = ON` is already in the schema, so CASCADE will work
### 2. Store Tags: `src/main.c` — New `store_event_tags()` Function
Add a new function called after successful event INSERT in `store_event()`:
```c
// Insert denormalized tags into event_tags table for fast indexed lookups
int store_event_tags(const char* event_id, cJSON* tags) {
if (!g_db || !event_id || !tags || !cJSON_IsArray(tags)) {
return 0; // Not an error if no tags
}
const char* sql = "INSERT INTO event_tags (event_id, tag_name, tag_value, tag_index) VALUES (?, ?, ?, ?)";
sqlite3_stmt* stmt;
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("Failed to prepare event_tags insert: %s", sqlite3_errmsg(g_db));
return -1;
}
int tag_index = 0;
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, tags) {
if (cJSON_IsArray(tag) && cJSON_GetArraySize(tag) >= 2) {
cJSON* name = cJSON_GetArrayItem(tag, 0);
cJSON* value = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(name) && cJSON_IsString(value)) {
sqlite3_reset(stmt);
sqlite3_bind_text(stmt, 1, event_id, -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 2, cJSON_GetStringValue(name), -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 3, cJSON_GetStringValue(value), -1, SQLITE_STATIC);
sqlite3_bind_int(stmt, 4, tag_index);
rc = sqlite3_step(stmt);
if (rc != SQLITE_DONE) {
DEBUG_ERROR("Failed to insert event tag: %s", sqlite3_errmsg(g_db));
}
}
}
tag_index++;
}
sqlite3_finalize(stmt);
return 0;
}
```
**Call site** — in `store_event()` at line ~784, after `monitoring_on_event_stored()`:
```c
// After successful event storage, insert denormalized tags
store_event_tags(cJSON_GetStringValue(id), tags);
```
### 3. Update REQ Query Builder: `src/main.c` — `handle_req_message()`
Replace the `json_each()` tag filter at lines 1243-1270 with an `event_tags` subquery:
**Before** (current code at line 1244):
```c
snprintf(sql_ptr, remaining,
" AND EXISTS (SELECT 1 FROM json_each(json(tags)) "
"WHERE json_extract(value, '$[0]') = ? "
"AND json_extract(value, '$[1]') IN (");
```
**After** (new code):
```c
snprintf(sql_ptr, remaining,
" AND id IN (SELECT event_id FROM event_tags "
"WHERE tag_name = ? AND tag_value IN (");
```
The rest of the parameter binding code (lines 1248-1270) stays the same — the bind params are identical (`tag_name` then `tag_value(s)`). Only the SQL template changes.
The closing parenthesis changes from `"))` to `"))` — same syntax, just different semantics.
### 4. NIP-09 Delete Cascade: `src/nip009.c`
The `ON DELETE CASCADE` foreign key handles this automatically. When `DELETE FROM events WHERE id = ?` executes, SQLite will automatically delete matching rows from `event_tags`. **No code changes needed in nip009.c** as long as `PRAGMA foreign_keys = ON` is set (it already is in the schema).
However, verify the replaceable/addressable event triggers in the schema also cascade properly. The existing triggers at schema lines 97-119 use `DELETE FROM events WHERE ...` which will trigger the CASCADE.
### 5. Performance Fix: Move Config Reads Out of Row Loop
In `handle_req_message()` at lines 1400-1401, `get_config_bool()` is called **inside the `while (sqlite3_step())` loop** — meaning it executes a SQLite query for every row returned. Move these before the loop:
**Before** (inside loop):
```c
while (sqlite3_step(stmt) == SQLITE_ROW) {
// ...
int expiration_enabled = get_config_bool("expiration_enabled", 1); // SQLite query per row!
int filter_responses = get_config_bool("expiration_filter", 1); // SQLite query per row!
```
**After** (before loop):
```c
int expiration_enabled = get_config_bool("expiration_enabled", 1);
int filter_responses = get_config_bool("expiration_filter", 1);
while (sqlite3_step(stmt) == SQLITE_ROW) {
// ... use cached values
```
### 6. Log Level Fixes
#### NIP-17 Decryption Failure — `src/websockets.c` lines 780 and 1488
Change from `DEBUG_ERROR` to `DEBUG_INFO`:
```c
// Before:
DEBUG_ERROR("NIP-17 admin message processing failed");
// After:
DEBUG_INFO("NIP-17 admin message processing failed");
```
This is expected behavior when non-admin gift wraps arrive — not an error condition.
#### Duplicate Event INSERT — `src/main.c` line 741
The `DEBUG_ERROR` at line 741 fires before the CONSTRAINT check at line 746. Suppress it for constraint violations:
```c
// Before (line 738-741):
if (rc != SQLITE_DONE) {
const char* err_msg = sqlite3_errmsg(g_db);
int extended_errcode = sqlite3_extended_errcode(g_db);
DEBUG_ERROR("INSERT failed: rc=%d, extended_errcode=%d, msg=%s", rc, extended_errcode, err_msg);
}
// After:
if (rc != SQLITE_DONE) {
const char* err_msg = sqlite3_errmsg(g_db);
int extended_errcode = sqlite3_extended_errcode(g_db);
if (rc != SQLITE_CONSTRAINT) {
DEBUG_ERROR("INSERT failed: rc=%d, extended_errcode=%d, msg=%s", rc, extended_errcode, err_msg);
}
}
```
### 7. Startup Tag Population
Since you said no migration code is needed (fresh deploy), the `event_tags` table will start empty and populate as new events arrive. Existing events won't have tags in the lookup table.
However, to avoid a period where old events don't appear in tag-filtered queries, add a one-time population function that runs at startup:
```c
// Populate event_tags from existing events (run once at startup)
int populate_event_tags_from_existing(void) {
if (!g_db) return -1;
// Check if event_tags is already populated
sqlite3_stmt* check_stmt;
sqlite3_prepare_v2(g_db, "SELECT COUNT(*) FROM event_tags", -1, &check_stmt, NULL);
if (sqlite3_step(check_stmt) == SQLITE_ROW && sqlite3_column_int(check_stmt, 0) > 0) {
sqlite3_finalize(check_stmt);
DEBUG_INFO("event_tags already populated, skipping");
return 0;
}
sqlite3_finalize(check_stmt);
DEBUG_INFO("Populating event_tags from existing events...");
const char* sql = "SELECT id, tags FROM events WHERE tags != '[]'";
sqlite3_stmt* stmt;
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) return -1;
// Use a transaction for bulk insert performance
sqlite3_exec(g_db, "BEGIN TRANSACTION", NULL, NULL, NULL);
int event_count = 0;
while (sqlite3_step(stmt) == SQLITE_ROW) {
const char* event_id = (const char*)sqlite3_column_text(stmt, 0);
const char* tags_json = (const char*)sqlite3_column_text(stmt, 1);
if (event_id && tags_json) {
cJSON* tags = cJSON_Parse(tags_json);
if (tags) {
store_event_tags(event_id, tags);
cJSON_Delete(tags);
event_count++;
}
}
}
sqlite3_finalize(stmt);
sqlite3_exec(g_db, "COMMIT", NULL, NULL, NULL);
DEBUG_INFO("Populated event_tags for %d events", event_count);
return 0;
}
```
Call this during startup, after database initialization but before accepting connections.
## Architecture Diagram
```mermaid
flowchart TD
subgraph "Current: O(n) per query"
A1["REQ with #g filter"] --> B1["SQL: SELECT ... WHERE EXISTS"]
B1 --> C1["json_each(json(tags))"]
C1 --> D1["json_extract per row"]
D1 --> E1["🔴 Full JSON parse per candidate row"]
end
subgraph "New: O(log n) per query"
A2["REQ with #g filter"] --> B2["SQL: SELECT ... WHERE id IN"]
B2 --> C2["event_tags table"]
C2 --> D2["idx_event_tags_lookup"]
D2 --> E2["🟢 B-tree index lookup"]
end
subgraph "Write Path (unchanged speed)"
F["EVENT arrives"] --> G["store_event()"]
G --> H["INSERT INTO events"]
H --> I["store_event_tags()"]
I --> J["INSERT INTO event_tags\n(one row per tag)"]
end
subgraph "Delete Path (automatic)"
K["NIP-09 DELETE"] --> L["DELETE FROM events"]
L --> M["ON DELETE CASCADE"]
M --> N["event_tags rows auto-deleted"]
end
```
## Testing Strategy
1. Run existing test suite: `tests/run_all_tests.sh`
2. Specifically run NIP tests that use tag filters: `tests/run_nip_tests.sh`
3. Verify tag-filtered REQ queries return correct results
4. Verify NIP-09 deletion cascades to event_tags
5. Verify replaceable event triggers cascade to event_tags
6. Monitor CPU usage after deployment with same traffic pattern
## Risk Assessment
- **Low risk**: The `event_tags` table is additive — it doesn't change the events table structure
- **Low risk**: `ON DELETE CASCADE` is a well-tested SQLite feature
- **Low risk**: The SQL change in `handle_req_message()` is a drop-in replacement (same bind params)
- **Medium risk**: Startup population on a large database could take a few seconds — but with only 55K events, this should complete in under 1 second
- **Write overhead**: Each event INSERT now also inserts ~5 rows into event_tags — negligible at 56 events/hour
+298
View File
@@ -0,0 +1,298 @@
# Web of Trust (WoT) Implementation Plan
## Feature Description
When enabled, the relay restricts access to pubkeys that the admin follows. The admin's kind 3 (contact list) event is used as the source of truth — all `p` tags in that event become whitelisted pubkeys. The admin themselves is always whitelisted.
Single config variable `wot_enabled` with three levels:
| Value | Mode | Effect |
|-------|------|--------|
| `0` | Off | Open relay — anyone can read and write |
| `1` | Write-only | Only followed pubkeys can **publish** events. Anyone can read/subscribe. |
| `2` | Full | Only followed pubkeys can **publish AND subscribe**. Requires NIP-42 auth for subscriptions. Eliminates subscription churn from anonymous connections. |
## How It Works
### WoT Sync Flow
```mermaid
flowchart TD
A["Admin publishes kind 3 event\n- contact list -"] --> B{wot_enabled > 0?}
B -->|No| C["Store event normally"]
B -->|Yes| D["Extract p tags from kind 3"]
D --> E["Clear existing WoT whitelist rules"]
E --> F["Insert new whitelist rules\nfor each followed pubkey"]
F --> G["Enable auth_enabled"]
G --> H{wot_enabled == 2?}
H -->|Yes| I["Enable nip42_auth_required_subscriptions"]
H -->|No| J["Done - write-only restriction"]
```
### Event Publishing Flow — Write Restriction (wot_enabled >= 1)
```mermaid
flowchart TD
A["EVENT message arrives"] --> B{auth_enabled?}
B -->|No| C["Accept event"]
B -->|Yes| D["check_database_auth_rules - pubkey -"]
D --> E{Pubkey in whitelist\nor wot_whitelist?}
E -->|Yes| C
E -->|No| F{Any whitelist rules exist?}
F -->|Yes| G["REJECT: not whitelisted"]
F -->|No| C
```
### Subscription Flow — Read Restriction (wot_enabled == 2)
```mermaid
flowchart TD
A["REQ message arrives"] --> B{wot_enabled == 2?}
B -->|No| C["Allow subscription"]
B -->|Yes| D{NIP-42 authenticated?}
D -->|No| E["Send AUTH challenge"]
D -->|Yes| F["check_database_auth_rules\nusing authenticated_pubkey"]
F --> G{Pubkey in whitelist\nor wot_whitelist?}
G -->|Yes| C
G -->|No| H["REJECT: not authorized\nfor subscriptions"]
```
## Design Decisions
### Leveraging Existing Infrastructure
The relay already has everything needed:
1. **`auth_rules` table** — stores whitelist/blacklist rules with `rule_type`, `pattern_type`, `pattern_value`
2. **`check_database_auth_rules()`** in [`request_validator.c:529`](src/request_validator.c:529) — already implements the logic: "if whitelist rules exist and pubkey is not whitelisted, deny"
3. **`add_auth_rule_from_config()`** / **`remove_auth_rule_from_config()`** in [`config.c`](src/config.c:2082) — already manage auth rules
4. **`event_tags` table** — can efficiently query `p` tags from kind 3 events
5. **Config system**`auth_enabled` flag already controls whether auth rules are checked
6. **NIP-42 auth**`nip42_auth_required_subscriptions` already gates REQ access, `pss->authenticated_pubkey` stores the authenticated pubkey
### WoT-Specific Whitelist Rules
To distinguish WoT-generated whitelist rules from manually-added ones, we use a new `rule_type` value: `wot_whitelist`. This allows:
- Clearing all WoT rules without affecting manual whitelist/blacklist rules
- Querying WoT status separately
- The existing `check_database_auth_rules()` function already checks for `rule_type = 'whitelist'` — we need to also match `wot_whitelist` in the whitelist check
### Trigger Mechanism
The WoT sync happens when:
1. **A kind 3 event from the admin is stored** — detected in `store_event()` after successful INSERT
2. **Startup** — if `wot_enabled > 0`, sync from the most recent admin kind 3 event in the database
3. **Admin DM command**`wot sync` to force a manual resync
### What Gets Whitelisted
- All pubkeys in `p` tags of the admin's kind 3 event
- The admin pubkey itself (always whitelisted)
- The relay pubkey (always whitelisted — for admin DM responses)
### What Is NOT Blocked
Even with WoT enabled, these are always allowed:
- Admin events (kind 23456) — already bypassed in [`request_validator.c:303`](src/request_validator.c:303)
- NIP-42 auth events (kind 22242) — already bypassed in [`request_validator.c:318`](src/request_validator.c:318)
- Kind 3 events from the admin — needed to update the follow list itself
- Kind 1059 gift wraps addressed to the relay — needed for admin DMs
## Files to Modify
| File | Changes |
|------|---------|
| `src/default_config_event.h` | Add `wot_enabled` config key (default: `0`) |
| `src/config.c` | Add `wot_sync_from_admin_kind3()` function |
| `src/main.c` | Add WoT trigger in `store_event()` when admin kind 3 is stored, add startup WoT sync |
| `src/request_validator.c` | Update whitelist SQL to also match `wot_whitelist` rule type |
| `src/sql_schema.h` | Update `auth_rules` CHECK constraint to include `wot_whitelist` |
| `src/websockets.c` | Add WoT pubkey check after NIP-42 auth check in REQ handler |
| `src/dm_admin.c` | Add `wot 0`, `wot 1`, `wot 2`, `wot sync`, `wot status` DM commands |
## Detailed Changes
### 1. Schema: `src/sql_schema.h`
Update the `auth_rules` table CHECK constraint to allow `wot_whitelist`:
```sql
-- Before:
rule_type TEXT NOT NULL CHECK (rule_type IN ('whitelist', 'blacklist', 'rate_limit', 'auth_required'))
-- After:
rule_type TEXT NOT NULL CHECK (rule_type IN ('whitelist', 'blacklist', 'rate_limit', 'auth_required', 'wot_whitelist'))
```
### 2. Config: `src/default_config_event.h`
Add WoT configuration:
```c
// Web of Trust Settings
// 0 = off, 1 = write-only (followed pubkeys can publish), 2 = full (followed pubkeys can publish AND subscribe)
{"wot_enabled", "0"},
```
### 3. Core WoT Sync Function: `src/config.c`
New function `wot_sync_from_admin_kind3()`:
```c
int wot_sync_from_admin_kind3(void) {
int wot_level = get_config_int("wot_enabled", 0);
if (wot_level <= 0) return 0; // WoT disabled
// 1. Get admin pubkey from config
// 2. Query event_tags for p tags from admin's latest kind 3 event:
// SELECT DISTINCT et.tag_value FROM event_tags et
// JOIN events e ON et.event_id = e.id
// WHERE e.kind = 3 AND e.pubkey = ? AND et.tag_name = 'p'
// ORDER BY e.created_at DESC
// 3. BEGIN TRANSACTION
// 4. DELETE FROM auth_rules WHERE rule_type = 'wot_whitelist'
// 5. INSERT wot_whitelist for admin pubkey
// 6. INSERT wot_whitelist for relay pubkey
// 7. For each p tag: INSERT INTO auth_rules (rule_type, pattern_type, pattern_value)
// VALUES ('wot_whitelist', 'pubkey', ?)
// 8. COMMIT
// 9. Set auth_enabled = true
// 10. If wot_level == 2: set nip42_auth_required_subscriptions = true
// 11. Log count of whitelisted pubkeys
return 0;
}
```
### 4. Trigger on Kind 3 Store: `src/main.c`
In `store_event()`, after successful INSERT and after `store_event_tags()`:
```c
// Check if this is a kind 3 event from the admin — trigger WoT sync
if ((int)cJSON_GetNumberValue(kind) == 3) {
int wot_level = get_config_int("wot_enabled", 0);
if (wot_level > 0) {
const char* admin_pubkey = get_config_value("admin_pubkey");
if (admin_pubkey && strcmp(cJSON_GetStringValue(pubkey), admin_pubkey) == 0) {
DEBUG_INFO("Admin kind 3 event stored — triggering WoT sync");
wot_sync_from_admin_kind3();
}
if (admin_pubkey) free((char*)admin_pubkey);
}
}
```
### 5. Startup WoT Sync: `src/main.c`
In `main()`, after `populate_event_tags_from_existing()`:
```c
// Sync Web of Trust whitelist if enabled
int wot_level = get_config_int("wot_enabled", 0);
if (wot_level > 0) {
wot_sync_from_admin_kind3();
}
```
### 6. Update Whitelist Check: `src/request_validator.c`
Update the whitelist SQL queries to also match `wot_whitelist`:
```c
// Before:
"SELECT rule_type FROM auth_rules WHERE rule_type = 'whitelist' AND pattern_type = 'pubkey' AND pattern_value = ? AND active = 1 LIMIT 1"
// After:
"SELECT rule_type FROM auth_rules WHERE rule_type IN ('whitelist', 'wot_whitelist') AND pattern_type = 'pubkey' AND pattern_value = ? AND active = 1 LIMIT 1"
```
And the whitelist-exists check:
```c
// Before:
"SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'whitelist' AND pattern_type = 'pubkey' AND active = 1 LIMIT 1"
// After:
"SELECT COUNT(*) FROM auth_rules WHERE rule_type IN ('whitelist', 'wot_whitelist') AND pattern_type = 'pubkey' AND active = 1 LIMIT 1"
```
### 7. REQ Handler WoT Check: `src/websockets.c`
When `wot_enabled == 2`, add a pubkey whitelist check **after** the existing NIP-42 auth check in the REQ handler. The existing code at line 903 already requires NIP-42 auth when `nip42_auth_required_subscriptions` is set. We add a WoT check right after:
```c
// Existing NIP-42 auth check (line 903):
if (pss && pss->nip42_auth_required_subscriptions && !pss->authenticated) {
// ... send AUTH challenge or NOTICE ...
return 0;
}
// NEW: WoT read restriction check (wot_enabled == 2)
if (pss && pss->authenticated && get_config_int("wot_enabled", 0) == 2) {
// Client is authenticated — check if their pubkey is in the WoT whitelist
int wot_result = check_database_auth_rules(pss->authenticated_pubkey, "subscription", NULL);
if (wot_result != NOSTR_SUCCESS) {
send_notice_message(wsi, pss, "restricted: your pubkey is not in this relay's web of trust");
DEBUG_INFO("REQ rejected: pubkey %s not in WoT whitelist", pss->authenticated_pubkey);
cJSON_Delete(json);
return 0;
}
}
```
### 8. Admin DM Commands: `src/dm_admin.c`
Add plain text DM commands:
| Command | Action |
|---------|--------|
| `wot 0` or `wot off` | Disable WoT, delete all `wot_whitelist` rules, reset `nip42_auth_required_subscriptions` |
| `wot 1` or `wot write` | Write-only WoT — sync follow list, set `auth_enabled=true` |
| `wot 2` or `wot full` | Full WoT — sync follow list, set `auth_enabled=true`, set `nip42_auth_required_subscriptions=true` |
| `wot sync` | Force resync from admin's kind 3 event |
| `wot status` | Show WoT level (0/1/2), count of whitelisted pubkeys |
### 9. NIP-11 Update
When WoT is enabled (level 1 or 2), the relay should indicate this in NIP-11 relay info. Add to the `limitation` object:
```json
"auth_required": true
```
## Edge Cases
1. **Admin has no kind 3 event in database**: WoT sync does nothing, logs a warning. No whitelist rules created = open relay.
2. **Admin updates follow list**: New kind 3 event triggers full resync — old WoT rules are cleared, new ones inserted. This is atomic (transaction).
3. **WoT disabled (set to 0)**: Clears all `wot_whitelist` rules. Manual `whitelist` rules are preserved. `nip42_auth_required_subscriptions` is reset to false.
4. **Admin unfollows someone**: Next kind 3 event triggers resync, the unfollowed pubkey's `wot_whitelist` rule is removed (full clear + reinsert).
5. **Kind 1059 gift wraps**: These need special handling — the relay needs to accept gift wraps addressed to it even from non-whitelisted pubkeys (for admin DMs). The `is_nip17_gift_wrap_for_relay()` check should bypass WoT.
6. **Read restriction without NIP-42 support (level 2)**: If a client doesn't support NIP-42, they cannot authenticate and therefore cannot subscribe. This is by design — it's the most effective way to reduce subscription churn from anonymous connections.
7. **NIP-11 discovery**: Clients can check NIP-11 to see if `auth_required` is true before connecting, avoiding wasted connections.
8. **Changing level from 2 to 1**: `nip42_auth_required_subscriptions` is reset to false, allowing anonymous subscriptions again. WoT whitelist rules remain for write restriction.
## Testing Strategy
1. Enable write-only WoT via DM: `wot 1`
2. Verify admin can still publish events
3. Verify followed pubkeys can publish events
4. Verify non-followed pubkeys are rejected for EVENT
5. Verify non-followed pubkeys can still subscribe (REQ)
6. Enable full WoT via DM: `wot 2`
7. Verify unauthenticated clients get AUTH challenge on REQ
8. Verify authenticated non-followed pubkeys are rejected for REQ
9. Verify authenticated followed pubkeys can subscribe
10. Verify `wot status` shows correct level and count
11. Publish new kind 3 event, verify auto-resync
12. `wot 0` — verify all pubkeys can publish and subscribe again
13. Verify admin DMs still work when WoT is enabled
## Performance Considerations
- WoT sync is O(n) where n = number of follows (typically 100-2000)
- Uses a transaction for bulk insert — fast even for large follow lists
- Auth rule check is already indexed: `idx_auth_rules_pattern ON auth_rules(pattern_type, pattern_value)`
- No additional per-event overhead — the existing `check_database_auth_rules()` already runs on every event when auth is enabled
- **Level 2 directly addresses CPU load**: With `wot_enabled=2`, unauthenticated connections cannot create subscriptions, eliminating the ~120 REQ/minute churn from anonymous connections that was causing 99% CPU
+388
View File
@@ -0,0 +1,388 @@
# Web of Trust — Admin Web UI Plan
## Overview
Add a **Web of Trust** section to the existing Authorization page in the admin web interface. The section lets the admin see whether their kind 3 contact list is on the relay, select a WoT level, trigger a sync, and view the current whitelist count.
## Architecture
### Data Flow
```mermaid
flowchart LR
A[Web UI] -->|config_set wot_enabled N| B[Kind 23456 Event]
A -->|system_command wot_sync| B
A -->|system_command wot_status| B
B --> C[Relay Backend]
C -->|Kind 23457 Response| D[Web UI updates display]
```
### How It Works
1. **On page load** — the Authorization page already calls `loadAuthRules`. We add a parallel call to `loadWotStatus` which sends a `system_command` / `wot_status` event.
2. **Backend responds** with a kind 23457 JSON containing: `wot_enabled` level, `wot_whitelist_count`, and `admin_kind3_exists` boolean.
3. **UI renders** a card showing:
- Whether the admin's kind 3 event exists on the relay
- Current WoT level as a 3-option radio/button group
- Count of whitelisted pubkeys
- A SYNC button to force resync
4. **Changing level** sends `config_set` / `wot_enabled` / `0|1|2` via the existing admin API, then triggers `wot_sync` if level > 0.
5. **SYNC button** sends `system_command` / `wot_sync`.
## Detailed Changes
### 1. Backend: New system commands in `src/config.c`
Add two new branches to `handle_system_command_unified`:
#### `wot_status` command
Returns JSON response:
```json
{
"command": "wot_status",
"status": "success",
"wot_enabled": 2,
"admin_kind3_exists": true,
"wot_whitelist_count": 347,
"timestamp": 1234567890
}
```
Implementation:
- Read `wot_enabled` from config table
- Query `SELECT COUNT(*) FROM events WHERE kind = 3 AND pubkey = ?` with admin_pubkey to check kind 3 existence
- Query `SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'wot_whitelist' AND active = 1` for whitelist count
- Return as kind 23457 signed response
#### `wot_sync` command
Calls `wot_sync_from_admin_kind3` and returns result:
```json
{
"command": "wot_sync",
"status": "success",
"wot_whitelist_count": 347,
"timestamp": 1234567890
}
```
### 2. Backend: Hook `config_set` for `wot_enabled`
In `handle_config_set_unified`, after the config value is updated, add a check:
```c
// After successful update, trigger WoT sync if wot_enabled changed
if (strcmp(config_key, "wot_enabled") == 0) {
int new_level = atoi(config_value);
if (new_level > 0) {
wot_sync_from_admin_kind3();
} else {
// Level 0: clear wot_whitelist rules and reset auth flags
sqlite3_exec(g_db, "DELETE FROM auth_rules WHERE rule_type = 'wot_whitelist'", NULL, NULL, NULL);
update_config_in_table("nip42_auth_required_subscriptions", "false");
}
}
```
### 3. Frontend: HTML — WoT section in `api/index.html`
Add a new div **inside** the `authRulesSection`, before the auth rules table. This keeps WoT visually grouped with authorization:
```html
<!-- Web of Trust Section -->
<div id="wotSection" class="input-group">
<div class="section-header" style="font-size: 14px; margin-bottom: 10px;">
WEB OF TRUST
</div>
<!-- Kind 3 Status Indicator -->
<div id="wotKind3Status" class="wot-status-row">
<span>Admin Contact List (kind 3):</span>
<span id="wotKind3Indicator" class="wot-indicator wot-unknown">Checking...</span>
</div>
<!-- WoT Level Selector -->
<div class="wot-level-selector">
<label>WoT Level:</label>
<div class="inline-buttons">
<button type="button" id="wotLevel0Btn" class="wot-level-btn" onclick="setWotLevel(0)">
OFF
</button>
<button type="button" id="wotLevel1Btn" class="wot-level-btn" onclick="setWotLevel(1)">
WRITE ONLY
</button>
<button type="button" id="wotLevel2Btn" class="wot-level-btn" onclick="setWotLevel(2)">
FULL
</button>
</div>
<div class="wot-level-description" id="wotLevelDescription">
Level 0: Open relay — anyone can read and write
</div>
</div>
<!-- WoT Stats -->
<div class="wot-stats-row">
<span>Whitelisted Pubkeys:</span>
<span id="wotWhitelistCount"></span>
</div>
<!-- Sync Button -->
<div class="inline-buttons">
<button type="button" id="wotSyncBtn" onclick="syncWot()">SYNC FROM KIND 3</button>
<button type="button" id="wotRefreshBtn" onclick="loadWotStatus()">REFRESH STATUS</button>
</div>
</div>
<hr style="margin: 15px 0; border-color: var(--border-color);">
```
### 4. Frontend: CSS additions in `api/index.css`
```css
/* Web of Trust styles */
.wot-status-row, .wot-stats-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
font-size: 14px;
}
.wot-indicator {
padding: 2px 10px;
border-radius: 4px;
font-weight: bold;
font-size: 12px;
}
.wot-indicator.wot-found { background: #28a745; color: white; }
.wot-indicator.wot-missing { background: #dc3545; color: white; }
.wot-indicator.wot-unknown { background: #6c757d; color: white; }
.wot-level-selector { padding: 10px 0; }
.wot-level-selector label { display: block; margin-bottom: 5px; font-weight: bold; }
.wot-level-btn { min-width: 100px; }
.wot-level-btn.active {
background: var(--accent-color, #007bff);
color: white;
border-color: var(--accent-color, #007bff);
}
.wot-level-description {
font-size: 12px;
color: var(--text-secondary);
margin-top: 5px;
font-style: italic;
}
```
### 5. Frontend: JavaScript functions in `api/index.js`
#### `loadWotStatus` — query current WoT state
```javascript
async function loadWotStatus() {
try {
if (!isLoggedIn || !userPubkey || !relayPool) return;
const command_array = ["system_command", "wot_status"];
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) return;
const event = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
const signedEvent = await window.nostr.signEvent(event);
const url = relayConnectionUrl.value.trim();
await relayPool.publish([url], signedEvent);
log('WoT status query sent', 'INFO');
} catch (error) {
log('Failed to load WoT status: ' + error.message, 'ERROR');
}
}
```
#### `setWotLevel` — change WoT level via config_set
```javascript
async function setWotLevel(level) {
try {
if (!isLoggedIn || !userPubkey || !relayPool) return;
// Send config_set for wot_enabled
const command_array = ["config_set", "wot_enabled", String(level)];
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) return;
const event = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
const signedEvent = await window.nostr.signEvent(event);
const url = relayConnectionUrl.value.trim();
await relayPool.publish([url], signedEvent);
log('WoT level set to ' + level, 'INFO');
// Refresh status after a short delay
setTimeout(() => loadWotStatus(), 1500);
} catch (error) {
log('Failed to set WoT level: ' + error.message, 'ERROR');
}
}
```
#### `syncWot` — force WoT sync
```javascript
async function syncWot() {
try {
if (!isLoggedIn || !userPubkey || !relayPool) return;
const command_array = ["system_command", "wot_sync"];
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) return;
const event = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
const signedEvent = await window.nostr.signEvent(event);
const url = relayConnectionUrl.value.trim();
await relayPool.publish([url], signedEvent);
log('WoT sync command sent', 'INFO');
// Refresh status after sync completes
setTimeout(() => loadWotStatus(), 2000);
} catch (error) {
log('Failed to sync WoT: ' + error.message, 'ERROR');
}
}
```
#### `handleWotStatusResponse` — process backend response
```javascript
function handleWotStatusResponse(responseData) {
const kind3Indicator = document.getElementById('wotKind3Indicator');
const whitelistCount = document.getElementById('wotWhitelistCount');
const levelDesc = document.getElementById('wotLevelDescription');
// Update kind 3 indicator
if (kind3Indicator) {
if (responseData.admin_kind3_exists) {
kind3Indicator.textContent = 'Found ✓';
kind3Indicator.className = 'wot-indicator wot-found';
} else {
kind3Indicator.textContent = 'Not Found ✗';
kind3Indicator.className = 'wot-indicator wot-missing';
}
}
// Update whitelist count
if (whitelistCount) {
whitelistCount.textContent = responseData.wot_whitelist_count || 0;
}
// Update level buttons
const level = responseData.wot_enabled || 0;
['wotLevel0Btn', 'wotLevel1Btn', 'wotLevel2Btn'].forEach((id, i) => {
const btn = document.getElementById(id);
if (btn) {
btn.classList.toggle('active', i === level);
}
});
// Update description
const descriptions = [
'Level 0: Open relay — anyone can read and write',
'Level 1: Write-only — only followed pubkeys can publish events',
'Level 2: Full — only followed pubkeys can publish AND subscribe (NIP-42 required)'
];
if (levelDesc) {
levelDesc.textContent = descriptions[level] || descriptions[0];
}
// Enable/disable sync button based on kind 3 existence
const syncBtn = document.getElementById('wotSyncBtn');
if (syncBtn) {
syncBtn.disabled = !responseData.admin_kind3_exists;
}
}
```
#### Wire into existing response handler
In the existing `handleSystemCommandResponse` function, add:
```javascript
if (responseData.command === 'wot_status' || responseData.command === 'wot_sync') {
handleWotStatusResponse(responseData);
}
```
#### Wire into page navigation
In the `authorization` case of the page switch handler, add:
```javascript
case 'authorization':
loadAuthRules().catch(...);
loadWotStatus().catch(error => {
console.log('Auto-load WoT status failed: ' + error.message);
});
break;
```
## Files to Modify
| File | Changes |
|------|---------|
| `src/config.c` | Add `wot_status` and `wot_sync` branches to `handle_system_command_unified`; add WoT sync hook to `handle_config_set_unified` |
| `api/index.html` | Add WoT section HTML inside `authRulesSection` |
| `api/index.css` | Add WoT-specific CSS styles |
| `api/index.js` | Add `loadWotStatus`, `setWotLevel`, `syncWot`, `handleWotStatusResponse` functions; wire into page nav and response handler |
## Edge Cases
1. **Admin not logged in** — WoT section shows but buttons are disabled; status shows "Checking..."
2. **No kind 3 event** — Indicator shows red "Not Found ✗"; SYNC button is disabled; level selector still works but sync will whitelist only admin + relay
3. **Level change from 2 to 0** — Backend clears all wot_whitelist rules and resets nip42_auth_required_subscriptions
4. **Concurrent config_set and wot_sync** — The backend handles these sequentially via SQLite transactions; no race condition
## UI Mockup
```
┌─────────────────────────────────────────────┐
│ WEB OF TRUST │
│ │
│ Admin Contact List (kind 3): [Found ✓] │
│ │
│ WoT Level: │
│ [ OFF ] [ WRITE ONLY ] [ FULL ] │
│ Level 2: Full — only followed pubkeys can │
│ publish AND subscribe (NIP-42 required) │
│ │
│ Whitelisted Pubkeys: 347 │
│ │
│ [ SYNC FROM KIND 3 ] [ REFRESH STATUS ] │
├─────────────────────────────────────────────┤
│ ─────────────────────────────────────────── │
│ AUTH RULES MANAGEMENT │
│ ... existing auth rules table ... │
└─────────────────────────────────────────────┘
```
+1 -1
View File
@@ -1 +1 @@
2324020
2039374
+32 -32
View File
@@ -499,10 +499,10 @@ int generate_monitoring_event_for_type(const char* d_tag_value, cJSON* (*query_f
// Use the library function to create and sign the event
cJSON* signed_event = nostr_create_and_sign_event(
24567, // kind (ephemeral)
cJSON_GetStringValue(cJSON_GetObjectItem(monitoring_event, "content")), // content
cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(monitoring_event, "content")), // content
tags, // tags
relay_privkey, // private key
(time_t)cJSON_GetNumberValue(cJSON_GetObjectItem(monitoring_event, "created_at")) // timestamp
(time_t)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(monitoring_event, "created_at")) // timestamp
);
if (!signed_event) {
@@ -922,10 +922,10 @@ int send_admin_response(const char* sender_pubkey, const char* response_content,
// Use the library function to create and sign the event
cJSON* signed_event = nostr_create_and_sign_event(
23457, // kind
cJSON_GetStringValue(cJSON_GetObjectItem(response_event, "content")), // content
cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(response_event, "content")), // content
tags, // tags
relay_privkey, // private key
(time_t)cJSON_GetNumberValue(cJSON_GetObjectItem(response_event, "created_at")) // timestamp
(time_t)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(response_event, "created_at")) // timestamp
);
if (!signed_event) {
@@ -1169,7 +1169,7 @@ int handle_sql_query_unified(cJSON* event, const char* query, char* error_messag
}
// Get request event ID for response correlation
cJSON* request_id_obj = cJSON_GetObjectItem(event, "id");
cJSON* request_id_obj = cJSON_GetObjectItemCaseSensitive(event, "id");
if (!request_id_obj || !cJSON_IsString(request_id_obj)) {
snprintf(error_message, error_size, "Missing request event ID");
return -1;
@@ -1188,7 +1188,7 @@ int handle_sql_query_unified(cJSON* event, const char* query, char* error_messag
}
// Get sender pubkey for response
cJSON* sender_pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* sender_pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (!sender_pubkey_obj || !cJSON_IsString(sender_pubkey_obj)) {
free(result_json);
snprintf(error_message, error_size, "Missing sender pubkey");
@@ -1491,7 +1491,7 @@ int send_nip17_response(const char* sender_pubkey, const char* response_content,
}
// Fix the p tag in the gift wrap - library function may use wrong pubkey
cJSON* gift_wrap_tags = cJSON_GetObjectItem(gift_wraps[0], "tags");
cJSON* gift_wrap_tags = cJSON_GetObjectItemCaseSensitive(gift_wraps[0], "tags");
if (gift_wrap_tags && cJSON_IsArray(gift_wrap_tags)) {
// Find and replace the p tag with the correct user pubkey
cJSON* tag = NULL;
@@ -1627,13 +1627,13 @@ char* generate_stats_text(void) {
if (stats_obj) {
// Extract basic metrics
cJSON* total_events = cJSON_GetObjectItem(stats_obj, "total_events");
cJSON* db_size = cJSON_GetObjectItem(stats_obj, "database_size_bytes");
cJSON* oldest_event = cJSON_GetObjectItem(stats_obj, "database_created_at");
cJSON* newest_event = cJSON_GetObjectItem(stats_obj, "latest_event_at");
cJSON* time_stats = cJSON_GetObjectItem(stats_obj, "time_stats");
cJSON* event_kinds = cJSON_GetObjectItem(stats_obj, "event_kinds");
// cJSON* top_pubkeys = cJSON_GetObjectItem(stats_obj, "top_pubkeys");
cJSON* total_events = cJSON_GetObjectItemCaseSensitive(stats_obj, "total_events");
cJSON* db_size = cJSON_GetObjectItemCaseSensitive(stats_obj, "database_size_bytes");
cJSON* oldest_event = cJSON_GetObjectItemCaseSensitive(stats_obj, "database_created_at");
cJSON* newest_event = cJSON_GetObjectItemCaseSensitive(stats_obj, "latest_event_at");
cJSON* time_stats = cJSON_GetObjectItemCaseSensitive(stats_obj, "time_stats");
cJSON* event_kinds = cJSON_GetObjectItemCaseSensitive(stats_obj, "event_kinds");
// cJSON* top_pubkeys = cJSON_GetObjectItemCaseSensitive(stats_obj, "top_pubkeys");
long long total = total_events ? (long long)cJSON_GetNumberValue(total_events) : 0;
long long db_bytes = db_size ? (long long)cJSON_GetNumberValue(db_size) : 0;
@@ -1661,9 +1661,9 @@ char* generate_stats_text(void) {
// Extract time-based stats
long long last_24h = 0, last_7d = 0, last_30d = 0;
if (time_stats) {
cJSON* h24 = cJSON_GetObjectItem(time_stats, "last_24h");
cJSON* d7 = cJSON_GetObjectItem(time_stats, "last_7d");
cJSON* d30 = cJSON_GetObjectItem(time_stats, "last_30d");
cJSON* h24 = cJSON_GetObjectItemCaseSensitive(time_stats, "last_24h");
cJSON* d7 = cJSON_GetObjectItemCaseSensitive(time_stats, "last_7d");
cJSON* d30 = cJSON_GetObjectItemCaseSensitive(time_stats, "last_30d");
last_24h = h24 ? (long long)cJSON_GetNumberValue(h24) : 0;
last_7d = d7 ? (long long)cJSON_GetNumberValue(d7) : 0;
last_30d = d30 ? (long long)cJSON_GetNumberValue(d30) : 0;
@@ -1695,9 +1695,9 @@ char* generate_stats_text(void) {
if (event_kinds && cJSON_IsArray(event_kinds)) {
cJSON* kind_item = NULL;
cJSON_ArrayForEach(kind_item, event_kinds) {
cJSON* kind = cJSON_GetObjectItem(kind_item, "kind");
cJSON* count = cJSON_GetObjectItem(kind_item, "count");
cJSON* percentage = cJSON_GetObjectItem(kind_item, "percentage");
cJSON* kind = cJSON_GetObjectItemCaseSensitive(kind_item, "kind");
cJSON* count = cJSON_GetObjectItemCaseSensitive(kind_item, "count");
cJSON* percentage = cJSON_GetObjectItemCaseSensitive(kind_item, "percentage");
if (kind && count && percentage) {
// Format event kind (right-justified, minimum 5 chars wide with underscores)
@@ -1767,9 +1767,9 @@ char* generate_stats_text(void) {
// int rank = 1;
// cJSON* pubkey_item = NULL;
// cJSON_ArrayForEach(pubkey_item, top_pubkeys) {
// cJSON* pubkey = cJSON_GetObjectItem(pubkey_item, "pubkey");
// cJSON* event_count = cJSON_GetObjectItem(pubkey_item, "event_count");
// cJSON* percentage = cJSON_GetObjectItem(pubkey_item, "percentage");
// cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(pubkey_item, "pubkey");
// cJSON* event_count = cJSON_GetObjectItemCaseSensitive(pubkey_item, "event_count");
// cJSON* percentage = cJSON_GetObjectItemCaseSensitive(pubkey_item, "percentage");
// if (pubkey && event_count && percentage) {
// const char* pubkey_str = cJSON_GetStringValue(pubkey);
@@ -2457,7 +2457,7 @@ int handle_create_relay_event_command(cJSON* event, int kind, cJSON* event_data,
}
// Get request event ID for response correlation
cJSON* request_id_obj = cJSON_GetObjectItem(event, "id");
cJSON* request_id_obj = cJSON_GetObjectItemCaseSensitive(event, "id");
if (!request_id_obj || !cJSON_IsString(request_id_obj)) {
snprintf(error_message, error_size, "Missing request event ID");
return -1;
@@ -2465,7 +2465,7 @@ int handle_create_relay_event_command(cJSON* event, int kind, cJSON* event_data,
const char* request_id = cJSON_GetStringValue(request_id_obj);
// Get sender pubkey for response
cJSON* sender_pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* sender_pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (!sender_pubkey_obj || !cJSON_IsString(sender_pubkey_obj)) {
snprintf(error_message, error_size, "Missing sender pubkey");
return -1;
@@ -2614,7 +2614,7 @@ cJSON* create_relay_dm_list_event(cJSON* dm_relays) {
}
// Extract relays array
cJSON* relays_array = cJSON_GetObjectItem(dm_relays, "relays");
cJSON* relays_array = cJSON_GetObjectItemCaseSensitive(dm_relays, "relays");
if (relays_array && cJSON_IsArray(relays_array)) {
cJSON* relay_item = NULL;
cJSON_ArrayForEach(relay_item, relays_array) {
@@ -2686,14 +2686,14 @@ cJSON* create_relay_list_event(cJSON* relays) {
}
// Extract relays array
cJSON* relays_array = cJSON_GetObjectItem(relays, "relays");
cJSON* relays_array = cJSON_GetObjectItemCaseSensitive(relays, "relays");
if (relays_array && cJSON_IsArray(relays_array)) {
cJSON* relay_item = NULL;
cJSON_ArrayForEach(relay_item, relays_array) {
if (cJSON_IsObject(relay_item)) {
cJSON* url = cJSON_GetObjectItem(relay_item, "url");
cJSON* read = cJSON_GetObjectItem(relay_item, "read");
cJSON* write = cJSON_GetObjectItem(relay_item, "write");
cJSON* url = cJSON_GetObjectItemCaseSensitive(relay_item, "url");
cJSON* read = cJSON_GetObjectItemCaseSensitive(relay_item, "read");
cJSON* write = cJSON_GetObjectItemCaseSensitive(relay_item, "write");
if (url && cJSON_IsString(url)) {
const char* relay_url = cJSON_GetStringValue(url);
@@ -2752,7 +2752,7 @@ int handle_monitoring_command(cJSON* event, const char* command, char* error_mes
}
// Get request event ID for response correlation
cJSON* request_id_obj = cJSON_GetObjectItem(event, "id");
cJSON* request_id_obj = cJSON_GetObjectItemCaseSensitive(event, "id");
if (!request_id_obj || !cJSON_IsString(request_id_obj)) {
snprintf(error_message, error_size, "Missing request event ID");
return -1;
@@ -2760,7 +2760,7 @@ int handle_monitoring_command(cJSON* event, const char* command, char* error_mes
const char* request_id = cJSON_GetStringValue(request_id_obj);
// Get sender pubkey for response
cJSON* sender_pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* sender_pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (!sender_pubkey_obj || !cJSON_IsString(sender_pubkey_obj)) {
snprintf(error_message, error_size, "Missing sender pubkey");
return -1;
+548 -55
View File
@@ -227,13 +227,13 @@ int store_config_event_in_database(const cJSON* event) {
}
// Get event fields
cJSON* id_obj = cJSON_GetObjectItem(event, "id");
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* created_at_obj = cJSON_GetObjectItem(event, "created_at");
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* content_obj = cJSON_GetObjectItem(event, "content");
cJSON* sig_obj = cJSON_GetObjectItem(event, "sig");
cJSON* tags_obj = cJSON_GetObjectItem(event, "tags");
cJSON* id_obj = cJSON_GetObjectItemCaseSensitive(event, "id");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
cJSON* created_at_obj = cJSON_GetObjectItemCaseSensitive(event, "created_at");
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* content_obj = cJSON_GetObjectItemCaseSensitive(event, "content");
cJSON* sig_obj = cJSON_GetObjectItemCaseSensitive(event, "sig");
cJSON* tags_obj = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!id_obj || !pubkey_obj || !created_at_obj || !kind_obj || !content_obj || !sig_obj || !tags_obj) {
return -1;
@@ -665,8 +665,8 @@ cJSON* create_default_config_event(const unsigned char* admin_privkey_bytes,
}
// Log success information
cJSON* id_obj = cJSON_GetObjectItem(event, "id");
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* id_obj = cJSON_GetObjectItemCaseSensitive(event, "id");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (id_obj && pubkey_obj) {
// printf(" Event ID: %s\n", cJSON_GetStringValue(id_obj));
@@ -951,12 +951,56 @@ static int validate_config_field(const char* key, const char* value, char* error
}
return 0;
}
// IP auth failure ban settings
if (strcmp(key, "auth_fail_ban_enabled") == 0) {
if (!is_valid_boolean(value)) {
snprintf(error_msg, error_size, "invalid boolean value '%s' for auth_fail_ban_enabled", value);
return -1;
}
return 0;
}
if (strcmp(key, "auth_fail_ban_threshold") == 0 ||
strcmp(key, "auth_fail_window_sec") == 0 ||
strcmp(key, "auth_fail_ban_duration_sec") == 0) {
if (!is_valid_positive_integer(value)) {
snprintf(error_msg, error_size, "invalid value '%s' for %s (must be positive integer)", value, key);
return -1;
}
return 0;
}
// NIP-42 auth timeout
if (strcmp(key, "nip42_auth_timeout_sec") == 0) {
if (!is_valid_positive_integer(value) && strcmp(value, "0") != 0) {
snprintf(error_msg, error_size, "invalid nip42_auth_timeout_sec '%s' (must be non-negative integer)", value);
return -1;
}
return 0;
}
// SQLite performance tuning
if (strcmp(key, "sqlite_mmap_size") == 0) {
if (!is_valid_positive_integer(value) && strcmp(value, "0") != 0) {
snprintf(error_msg, error_size, "invalid sqlite_mmap_size '%s' (must be non-negative integer bytes)", value);
return -1;
}
return 0;
}
if (strcmp(key, "sqlite_cache_size_kb") == 0) {
if (!is_valid_positive_integer(value)) {
snprintf(error_msg, error_size, "invalid sqlite_cache_size_kb '%s' (must be positive integer KB)", value);
return -1;
}
return 0;
}
// Boolean fields
if (strcmp(key, "auth_enabled") == 0 ||
strcmp(key, "nip40_expiration_enabled") == 0 ||
strcmp(key, "nip40_expiration_strict") == 0 ||
strcmp(key, "nip40_expiration_filter") == 0) {
strcmp(key, "nip40_expiration_filter") == 0 ||
strcmp(key, "nip17_admin_enabled") == 0) {
if (!is_valid_boolean(value)) {
snprintf(error_msg, error_size, "invalid boolean value '%s' for %s", value, key);
return -1;
@@ -1256,7 +1300,7 @@ static int validate_configuration_event_fields(const cJSON* event, char* error_m
}
cJSON* tags = cJSON_GetObjectItem(event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags || !cJSON_IsArray(tags)) {
snprintf(error_msg, error_size, "missing or invalid tags array");
return -1;
@@ -1320,8 +1364,8 @@ int process_configuration_event(const cJSON* event) {
}
// Validate event structure
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (!kind_obj || cJSON_GetNumberValue(kind_obj) != 33334) {
DEBUG_ERROR("Invalid event kind for configuration");
@@ -1395,7 +1439,7 @@ extern void init_relay_info(void);
static const char* get_config_value_from_event(const cJSON* event, const char* key) {
if (!event || !key) return NULL;
cJSON* tags = cJSON_GetObjectItem(event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags || !cJSON_IsArray(tags)) return NULL;
cJSON* tag = NULL;
@@ -1718,13 +1762,16 @@ int populate_default_config_values(void) {
strcmp(key, "default_limit") == 0 ||
strcmp(key, "max_limit") == 0 ||
strcmp(key, "nip42_challenge_expiration") == 0 ||
strcmp(key, "nip40_expiration_grace_period") == 0) {
strcmp(key, "nip40_expiration_grace_period") == 0 ||
strcmp(key, "sqlite_mmap_size") == 0 ||
strcmp(key, "sqlite_cache_size_kb") == 0) {
data_type = "integer";
} else if (strcmp(key, "auth_enabled") == 0 ||
strcmp(key, "nip40_expiration_enabled") == 0 ||
strcmp(key, "nip40_expiration_strict") == 0 ||
strcmp(key, "nip40_expiration_filter") == 0 ||
strcmp(key, "nip42_auth_required") == 0) {
strcmp(key, "nip42_auth_required") == 0 ||
strcmp(key, "nip17_admin_enabled") == 0) {
data_type = "boolean";
}
@@ -1937,7 +1984,7 @@ extern int is_authorized_admin_event(cJSON* event);
// Process admin events (updated for Kind 23456)
int process_admin_event_in_config(cJSON* event, char* error_message, size_t error_size, struct lws* wsi) {
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
if (!kind_obj || !cJSON_IsNumber(kind_obj)) {
DEBUG_ERROR("Missing or invalid kind in admin event");
snprintf(error_message, error_size, "invalid: missing or invalid kind");
@@ -1962,7 +2009,7 @@ int process_admin_event_in_config(cJSON* event, char* error_message, size_t erro
int process_admin_config_event(cJSON* event, char* error_message, size_t error_size) {
// Parse tags to find query commands according to API specification
cJSON* tags_obj = cJSON_GetObjectItem(event, "tags");
cJSON* tags_obj = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (tags_obj && cJSON_IsArray(tags_obj)) {
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, tags_obj) {
@@ -2052,7 +2099,7 @@ int process_admin_config_event(cJSON* event, char* error_message, size_t error_s
// Handle Kind 23456 auth rules management
int process_admin_auth_event(cJSON* event, char* error_message, size_t error_size, struct lws* wsi) {
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
int kind = kind_obj ? (int)cJSON_GetNumberValue(kind_obj) : 0;
// Extract and log additional event details for debugging
@@ -2129,6 +2176,296 @@ int remove_auth_rule_from_config(const char* rule_type, const char* pattern_type
return (rc == SQLITE_DONE) ? 0 : -1;
}
// ================================
// WEB OF TRUST (WoT) SYNC FUNCTION
// ================================
/**
* Sync Web of Trust whitelist from admin's kind 3 (contact list) event
* Extracts p tags from the admin's most recent kind 3 event and creates
* wot_whitelist auth rules for each followed pubkey.
*
* @return 0 on success, -1 on error
*/
int wot_sync_from_admin_kind3(void) {
if (!g_db) {
DEBUG_ERROR("WoT sync: database not available");
return -1;
}
// Check if WoT is enabled
int wot_level = get_config_int("wot_enabled", 0);
if (wot_level <= 0) {
DEBUG_INFO("WoT sync: wot_enabled is %d, skipping sync", wot_level);
return 0; // WoT disabled, nothing to do
}
// Get admin pubkey from config
const char* admin_pubkey = get_config_value("admin_pubkey");
if (!admin_pubkey) {
DEBUG_ERROR("WoT sync: admin_pubkey not configured");
return -1;
}
// Get relay pubkey from config
const char* relay_pubkey = get_config_value("relay_pubkey");
if (!relay_pubkey) {
free((char*)admin_pubkey);
DEBUG_ERROR("WoT sync: relay_pubkey not configured");
return -1;
}
DEBUG_INFO("WoT sync: Starting sync from admin's kind 3 event (level %d)", wot_level);
sqlite3_stmt* stmt = NULL;
int rc;
int whitelisted_count = 0;
// Step 1: Query p tags from admin's most recent kind 3 event
const char* query_sql =
"SELECT DISTINCT et.tag_value FROM event_tags et "
"JOIN events e ON et.event_id = e.id "
"WHERE e.kind = 3 AND e.pubkey = ? AND et.tag_name = 'p' "
"AND e.created_at = (SELECT MAX(created_at) FROM events WHERE kind = 3 AND pubkey = ?)";
rc = sqlite3_prepare_v2(g_db, query_sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("WoT sync: Failed to prepare p-tag query: %s", sqlite3_errmsg(g_db));
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
sqlite3_bind_text(stmt, 1, admin_pubkey, -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 2, admin_pubkey, -1, SQLITE_STATIC);
// Collect all p tags
char** p_tags = NULL;
int p_tag_count = 0;
int p_tag_capacity = 16;
p_tags = malloc(p_tag_capacity * sizeof(char*));
if (!p_tags) {
DEBUG_ERROR("WoT sync: Failed to allocate p_tag array");
sqlite3_finalize(stmt);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
const char* p_tag = (const char*)sqlite3_column_text(stmt, 0);
if (p_tag) {
// Expand array if needed
if (p_tag_count >= p_tag_capacity) {
p_tag_capacity *= 2;
char** new_tags = realloc(p_tags, p_tag_capacity * sizeof(char*));
if (!new_tags) {
DEBUG_ERROR("WoT sync: Failed to expand p_tag array");
// Free existing tags
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
sqlite3_finalize(stmt);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
p_tags = new_tags;
}
p_tags[p_tag_count] = strdup(p_tag);
p_tag_count++;
}
}
sqlite3_finalize(stmt);
// If no kind 3 event found, just log and continue (admin and relay will still be whitelisted)
if (p_tag_count == 0) {
DEBUG_WARN("WoT sync: No p tags found in admin's kind 3 event");
} else {
DEBUG_INFO("WoT sync: Found %d followed pubkeys in kind 3 event", p_tag_count);
}
// Step 2: Begin transaction
rc = sqlite3_exec(g_db, "BEGIN TRANSACTION", NULL, NULL, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("WoT sync: Failed to begin transaction: %s", sqlite3_errmsg(g_db));
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
// Step 3: Clear existing wot_whitelist rules
const char* delete_sql = "DELETE FROM auth_rules WHERE rule_type = 'wot_whitelist'";
rc = sqlite3_exec(g_db, delete_sql, NULL, NULL, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("WoT sync: Failed to clear existing wot_whitelist rules: %s", sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
DEBUG_TRACE("WoT sync: Cleared existing wot_whitelist rules");
// Step 4: Insert admin pubkey as wot_whitelist (always allowed)
const char* insert_sql =
"INSERT INTO auth_rules (rule_type, pattern_type, pattern_value, active) "
"VALUES ('wot_whitelist', 'pubkey', ?, 1)";
rc = sqlite3_prepare_v2(g_db, insert_sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("WoT sync: Failed to prepare insert statement: %s", sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
sqlite3_bind_text(stmt, 1, admin_pubkey, -1, SQLITE_STATIC);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
DEBUG_ERROR("WoT sync: Failed to insert admin pubkey whitelist: %s", sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
whitelisted_count++;
DEBUG_TRACE("WoT sync: Whitelisted admin pubkey: %.16s...", admin_pubkey);
// Step 5: Insert relay pubkey as wot_whitelist (needed for DM responses)
rc = sqlite3_prepare_v2(g_db, insert_sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("WoT sync: Failed to prepare insert statement for relay: %s", sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
sqlite3_bind_text(stmt, 1, relay_pubkey, -1, SQLITE_STATIC);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
DEBUG_ERROR("WoT sync: Failed to insert relay pubkey whitelist: %s", sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
whitelisted_count++;
DEBUG_TRACE("WoT sync: Whitelisted relay pubkey: %.16s...", relay_pubkey);
// Step 6: Insert each p tag from kind 3 event
for (int i = 0; i < p_tag_count; i++) {
rc = sqlite3_prepare_v2(g_db, insert_sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("WoT sync: Failed to prepare insert for p_tag %d: %s", i, sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
// Free remaining tags
for (int j = i; j < p_tag_count; j++) {
free(p_tags[j]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
sqlite3_bind_text(stmt, 1, p_tags[i], -1, SQLITE_STATIC);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
DEBUG_ERROR("WoT sync: Failed to insert p_tag %d: %s", i, sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
// Free remaining tags
for (int j = i; j < p_tag_count; j++) {
free(p_tags[j]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
whitelisted_count++;
}
// Step 7: Commit transaction
rc = sqlite3_exec(g_db, "COMMIT", NULL, NULL, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("WoT sync: Failed to commit transaction: %s", sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
// Step 8: Enable auth_enabled and optionally nip42_auth_required_subscriptions
if (update_config_in_table("auth_enabled", "true") != 0) {
DEBUG_WARN("WoT sync: Failed to set auth_enabled");
} else {
DEBUG_INFO("WoT sync: Enabled auth_enabled");
}
if (wot_level == 2) {
if (update_config_in_table("nip42_auth_required_subscriptions", "true") != 0) {
DEBUG_WARN("WoT sync: Failed to set nip42_auth_required_subscriptions");
} else {
DEBUG_INFO("WoT sync: Enabled nip42_auth_required_subscriptions for level 2");
}
} else {
// Level 1: ensure nip42_auth_required_subscriptions is false
if (update_config_in_table("nip42_auth_required_subscriptions", "false") != 0) {
DEBUG_WARN("WoT sync: Failed to reset nip42_auth_required_subscriptions");
}
}
// Cleanup
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
DEBUG_INFO("WoT sync: Complete - whitelisted %d pubkeys (admin + relay + %d follows)",
whitelisted_count, p_tag_count);
return 0;
}
// ================================
// UNIFIED TAG PARSING UTILITIES
// ================================
@@ -2137,7 +2474,7 @@ int remove_auth_rule_from_config(const char* rule_type, const char* pattern_type
const char* get_first_tag_name(cJSON* event) {
if (!event) return NULL;
cJSON* tags_obj = cJSON_GetObjectItem(event, "tags");
cJSON* tags_obj = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags_obj || !cJSON_IsArray(tags_obj)) return NULL;
cJSON* first_tag = cJSON_GetArrayItem(tags_obj, 0);
@@ -2155,7 +2492,7 @@ const char* get_first_tag_name(cJSON* event) {
const char* get_tag_value(cJSON* event, const char* tag_name, int value_index) {
if (!event || !tag_name) return NULL;
cJSON* tags_obj = cJSON_GetObjectItem(event, "tags");
cJSON* tags_obj = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags_obj || !cJSON_IsArray(tags_obj)) return NULL;
cJSON* tag = NULL;
@@ -2265,8 +2602,8 @@ cJSON* create_admin_response_event(const char* encrypted_content, const char* re
}
// Log success information
cJSON* id_obj = cJSON_GetObjectItem(response_event, "id");
cJSON* pubkey_obj = cJSON_GetObjectItem(response_event, "pubkey");
cJSON* id_obj = cJSON_GetObjectItemCaseSensitive(response_event, "id");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(response_event, "pubkey");
if (id_obj && pubkey_obj) {
// printf(" Event ID: %s\n", cJSON_GetStringValue(id_obj));
@@ -2379,7 +2716,7 @@ int send_admin_response_event(const cJSON* response_data, const char* recipient_
return -1;
}
cJSON* id_obj = cJSON_GetObjectItem(response_event, "id");
cJSON* id_obj = cJSON_GetObjectItemCaseSensitive(response_event, "id");
if (id_obj) {
// printf(" Event ID: %s\n", cJSON_GetStringValue(id_obj));
}
@@ -2473,7 +2810,7 @@ int handle_kind_23456_unified(cJSON* event, char* error_message, size_t error_si
}
// Verify the event sender is the authorized admin
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (!pubkey_obj || !cJSON_IsString(pubkey_obj)) {
DEBUG_ERROR("invalid: missing sender pubkey in event");
snprintf(error_message, error_size, "invalid: missing sender pubkey in event");
@@ -2498,7 +2835,7 @@ int handle_kind_23456_unified(cJSON* event, char* error_message, size_t error_si
}
// Check if content is encrypted (NIP-44)
cJSON* content_obj = cJSON_GetObjectItem(event, "content");
cJSON* content_obj = cJSON_GetObjectItemCaseSensitive(event, "content");
if (!content_obj || !cJSON_IsString(content_obj)) {
DEBUG_ERROR("invalid: missing or invalid content");
snprintf(error_message, error_size, "invalid: missing or invalid content");
@@ -2520,7 +2857,7 @@ int handle_kind_23456_unified(cJSON* event, char* error_message, size_t error_si
}
// Get sender's pubkey from the event for NIP-44 decryption
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (!pubkey_obj || !cJSON_IsString(pubkey_obj)) {
DEBUG_ERROR("invalid: missing sender pubkey in event");
free(relay_privkey);
@@ -2553,20 +2890,46 @@ int handle_kind_23456_unified(cJSON* event, char* error_message, size_t error_si
snprintf(error_message, error_size, "error: failed to convert sender public key");
return -1;
}
DEBUG_LOG("=== NIP-44 DECRYPTION DEBUG (Level 5) ===");
DEBUG_LOG("Relay privkey (first 16 chars): %.16s...", relay_privkey);
DEBUG_LOG("Sender pubkey: %s", sender_pubkey);
DEBUG_LOG("Content length: %zu", strlen(content));
DEBUG_LOG("Content (first 100 chars): %.100s%s", content, strlen(content) > 100 ? "..." : "");
// Perform NIP-44 decryption (relay as recipient, admin as sender)
DEBUG_LOG("Calling nostr_nip44_decrypt...");
DEBUG_LOG(" relay_privkey_bytes (first 8 bytes hex): %02x%02x%02x%02x%02x%02x%02x%02x...",
relay_privkey_bytes[0], relay_privkey_bytes[1], relay_privkey_bytes[2], relay_privkey_bytes[3],
relay_privkey_bytes[4], relay_privkey_bytes[5], relay_privkey_bytes[6], relay_privkey_bytes[7]);
DEBUG_LOG(" sender_pubkey_bytes (first 8 bytes hex): %02x%02x%02x%02x%02x%02x%02x%02x...",
sender_pubkey_bytes[0], sender_pubkey_bytes[1], sender_pubkey_bytes[2], sender_pubkey_bytes[3],
sender_pubkey_bytes[4], sender_pubkey_bytes[5], sender_pubkey_bytes[6], sender_pubkey_bytes[7]);
char decrypted_text[16384]; // Buffer for decrypted content (16KB)
int decrypt_result = nostr_nip44_decrypt(relay_privkey_bytes, sender_pubkey_bytes, content, decrypted_text, sizeof(decrypted_text));
DEBUG_LOG("nostr_nip44_decrypt returned: %d (NOSTR_SUCCESS=%d)", decrypt_result, NOSTR_SUCCESS);
// Clean up private key immediately after use
memset(relay_privkey_bytes, 0, 32);
free(relay_privkey);
if (decrypt_result != NOSTR_SUCCESS) {
DEBUG_ERROR("error: NIP-44 decryption failed");
snprintf(error_message, error_size, "error: NIP-44 decryption failed");
DEBUG_ERROR("error: NIP-44 decryption failed with code %d", decrypt_result);
DEBUG_ERROR(" This means the encrypted content cannot be decrypted with the provided keys");
DEBUG_ERROR(" Possible causes:");
DEBUG_ERROR(" 1. Content was encrypted for a different relay pubkey");
DEBUG_ERROR(" 2. Content format is incompatible (wrong NIP-44 version)");
DEBUG_ERROR(" 3. Content is corrupted or malformed");
snprintf(error_message, error_size, "error: NIP-44 decryption failed (code: %d)", decrypt_result);
return -1;
}
DEBUG_LOG("✓ NIP-44 decryption successful!");
DEBUG_LOG("Decrypted text length: %zu", strlen(decrypted_text));
DEBUG_LOG("Decrypted text (first 200 chars): %.200s%s", decrypted_text, strlen(decrypted_text) > 200 ? "..." : "");
DEBUG_LOG("=== END NIP-44 DECRYPTION DEBUG ===");
// Parse decrypted content as command array directly (NOT as NIP-17 inner event)
// Kind 23456 events contain direct command arrays: ["command_name", arg1, arg2, ...]
@@ -2625,7 +2988,7 @@ int handle_kind_23456_unified(cJSON* event, char* error_message, size_t error_si
}
// Add existing tags
cJSON* existing_tags = cJSON_GetObjectItem(event, "tags");
cJSON* existing_tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (existing_tags && cJSON_IsArray(existing_tags)) {
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, existing_tags) {
@@ -2813,7 +3176,7 @@ int handle_auth_query_unified(cJSON* event, const char* query_type, char* error_
cJSON* response = build_query_response(mapped_query_type, results_array, rule_count);
if (response) {
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
@@ -2928,7 +3291,7 @@ int handle_config_query_unified(cJSON* event, const char* query_type, char* erro
cJSON* response = build_query_response(mapped_query_type, results_array, config_count);
if (response) {
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
@@ -2999,6 +3362,20 @@ int handle_config_set_unified(cJSON* event, const char* config_key, const char*
return -1;
}
// Special handling for wot_enabled changes - trigger sync or cleanup
if (strcmp(config_key, "wot_enabled") == 0) {
int new_level = atoi(config_value);
if (new_level > 0) {
DEBUG_INFO("Config set: wot_enabled changed to %d, triggering WoT sync", new_level);
wot_sync_from_admin_kind3();
} else {
// Level 0: clear wot_whitelist rules and reset auth flags
DEBUG_INFO("Config set: wot_enabled changed to 0, clearing wot_whitelist rules");
sqlite3_exec(g_db, "DELETE FROM auth_rules WHERE rule_type = 'wot_whitelist'", NULL, NULL, NULL);
update_config_in_table("auth_enabled", "false");
update_config_in_table("nip42_auth_required_subscriptions", "false");
}
}
// Build response
cJSON* response = cJSON_CreateObject();
@@ -3010,7 +3387,7 @@ int handle_config_set_unified(cJSON* event, const char* config_key, const char*
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
@@ -3074,7 +3451,7 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
@@ -3151,7 +3528,7 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
@@ -3212,7 +3589,7 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
printf("Cache status: Not used (direct database queries)\n");
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
@@ -3244,7 +3621,7 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
printf("Sending acknowledgment and initiating shutdown...\n");
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
@@ -3266,6 +3643,118 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
snprintf(error_message, error_size, "failed to send restart acknowledgment");
return -1;
}
else if (strcmp(command, "wot_status") == 0) {
// Get admin pubkey from event
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
snprintf(error_message, error_size, "missing admin pubkey for WoT status");
return -1;
}
// Get current WoT level
int wot_enabled = get_config_int("wot_enabled", 0);
// Check if admin's kind 3 event exists
int admin_kind3_exists = 0;
const char* kind3_sql = "SELECT COUNT(*) FROM events WHERE kind = 3 AND pubkey = ?";
sqlite3_stmt* kind3_stmt;
int rc = sqlite3_prepare_v2(g_db, kind3_sql, -1, &kind3_stmt, NULL);
if (rc == SQLITE_OK) {
sqlite3_bind_text(kind3_stmt, 1, admin_pubkey, -1, SQLITE_STATIC);
if (sqlite3_step(kind3_stmt) == SQLITE_ROW) {
admin_kind3_exists = sqlite3_column_int(kind3_stmt, 0) > 0;
}
sqlite3_finalize(kind3_stmt);
}
// Count WoT whitelist entries
int wot_whitelist_count = 0;
const char* count_sql = "SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'wot_whitelist' AND active = 1";
sqlite3_stmt* count_stmt;
if (sqlite3_prepare_v2(g_db, count_sql, -1, &count_stmt, NULL) == SQLITE_OK) {
if (sqlite3_step(count_stmt) == SQLITE_ROW) {
wot_whitelist_count = sqlite3_column_int(count_stmt, 0);
}
sqlite3_finalize(count_stmt);
}
// Build response
cJSON* response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "command", "wot_status");
cJSON_AddStringToObject(response, "status", "success");
cJSON_AddNumberToObject(response, "wot_enabled", wot_enabled);
cJSON_AddBoolToObject(response, "admin_kind3_exists", admin_kind3_exists);
cJSON_AddNumberToObject(response, "wot_whitelist_count", wot_whitelist_count);
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
// Send response as signed kind 23457 event
if (send_admin_response_event(response, admin_pubkey, wsi) == 0) {
cJSON_Delete(response);
return 0;
}
cJSON_Delete(response);
snprintf(error_message, error_size, "failed to send WoT status response");
return -1;
}
else if (strcmp(command, "wot_sync") == 0) {
// Get admin pubkey from event
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
snprintf(error_message, error_size, "missing admin pubkey for WoT sync");
return -1;
}
// Check if WoT is enabled
int wot_enabled = get_config_int("wot_enabled", 0);
if (wot_enabled <= 0) {
snprintf(error_message, error_size, "WoT is not enabled (wot_enabled=0)");
return -1;
}
// Trigger WoT sync
int sync_result = wot_sync_from_admin_kind3();
// Get updated whitelist count
int wot_whitelist_count = 0;
const char* count_sql = "SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'wot_whitelist' AND active = 1";
sqlite3_stmt* count_stmt;
if (sqlite3_prepare_v2(g_db, count_sql, -1, &count_stmt, NULL) == SQLITE_OK) {
if (sqlite3_step(count_stmt) == SQLITE_ROW) {
wot_whitelist_count = sqlite3_column_int(count_stmt, 0);
}
sqlite3_finalize(count_stmt);
}
// Build response
cJSON* response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "command", "wot_sync");
cJSON_AddStringToObject(response, "status", sync_result == 0 ? "success" : "error");
cJSON_AddNumberToObject(response, "wot_enabled", wot_enabled);
cJSON_AddNumberToObject(response, "wot_whitelist_count", wot_whitelist_count);
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
if (sync_result != 0) {
cJSON_AddStringToObject(response, "message", "WoT sync failed");
}
// Send response as signed kind 23457 event
if (send_admin_response_event(response, admin_pubkey, wsi) == 0) {
cJSON_Delete(response);
return sync_result;
}
cJSON_Delete(response);
snprintf(error_message, error_size, "failed to send WoT sync response");
return -1;
}
else {
snprintf(error_message, error_size, "invalid: unknown system command '%s'", command);
return -1;
@@ -3276,7 +3765,7 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
int handle_auth_rule_modification_unified(cJSON* event, char* error_message, size_t error_size, struct lws* wsi) {
// Suppress unused parameter warning
(void)wsi;
cJSON* tags_obj = cJSON_GetObjectItem(event, "tags");
cJSON* tags_obj = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags_obj || !cJSON_IsArray(tags_obj)) {
snprintf(error_message, error_size, "invalid: auth rule event must have tags");
return -1;
@@ -3359,7 +3848,7 @@ int handle_auth_rule_modification_unified(cJSON* event, char* error_message, siz
printf("Processed %d auth rule modifications\n", rules_processed);
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
@@ -3489,7 +3978,7 @@ int handle_stats_query_unified(cJSON* event, char* error_message, size_t error_s
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
@@ -3557,7 +4046,7 @@ int handle_config_update_unified(cJSON* event, char* error_message, size_t error
// Extract config objects array from synthetic tags created by NIP-44 decryption
// The decryption process creates synthetic tags like: ["config_update", [config_objects]]
cJSON* tags_obj = cJSON_GetObjectItem(event, "tags");
cJSON* tags_obj = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags_obj || !cJSON_IsArray(tags_obj)) {
snprintf(error_message, error_size, "invalid: config update event must have tags");
return -1;
@@ -3638,10 +4127,10 @@ int handle_config_update_unified(cJSON* event, char* error_message, size_t error
}
// Extract required fields from config object
cJSON* key_obj = cJSON_GetObjectItem(config_obj, "key");
cJSON* value_obj = cJSON_GetObjectItem(config_obj, "value");
cJSON* data_type_obj = cJSON_GetObjectItem(config_obj, "data_type");
cJSON* category_obj = cJSON_GetObjectItem(config_obj, "category");
cJSON* key_obj = cJSON_GetObjectItemCaseSensitive(config_obj, "key");
cJSON* value_obj = cJSON_GetObjectItemCaseSensitive(config_obj, "value");
cJSON* data_type_obj = cJSON_GetObjectItemCaseSensitive(config_obj, "data_type");
cJSON* category_obj = cJSON_GetObjectItemCaseSensitive(config_obj, "category");
if (!key_obj || !cJSON_IsString(key_obj) ||
!value_obj || !cJSON_IsString(value_obj)) {
@@ -3838,7 +4327,7 @@ int handle_config_update_unified(cJSON* event, char* error_message, size_t error
cJSON_AddItemToObject(error_response, "data", processed_configs);
// Get admin pubkey from event for error response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (admin_pubkey) {
@@ -3866,7 +4355,7 @@ int handle_config_update_unified(cJSON* event, char* error_message, size_t error
cJSON_AddItemToObject(error_response, "data", processed_configs);
// Get admin pubkey from event for error response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (admin_pubkey) {
@@ -3895,7 +4384,7 @@ int handle_config_update_unified(cJSON* event, char* error_message, size_t error
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
@@ -4055,7 +4544,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 +4554,8 @@ int populate_all_config_values_atomic(const char* admin_pubkey, const char* rela
strcmp(key, "nip40_expiration_filter") == 0 ||
strcmp(key, "nip42_auth_required_events") == 0 ||
strcmp(key, "nip42_auth_required_subscriptions") == 0 ||
strcmp(key, "nip70_protected_events_enabled") == 0) {
strcmp(key, "nip70_protected_events_enabled") == 0 ||
strcmp(key, "nip17_admin_enabled") == 0) {
data_type = "boolean";
}
@@ -4298,7 +4790,7 @@ int populate_config_table_from_event(const cJSON* event) {
DEBUG_INFO("Populating config table from configuration event...");
cJSON* tags = cJSON_GetObjectItem(event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags || !cJSON_IsArray(tags)) {
DEBUG_ERROR("Configuration event missing tags array");
return -1;
@@ -4348,7 +4840,8 @@ int populate_config_table_from_event(const cJSON* event) {
strcmp(key, "nip40_expiration_enabled") == 0 ||
strcmp(key, "nip40_expiration_strict") == 0 ||
strcmp(key, "nip40_expiration_filter") == 0 ||
strcmp(key, "nip42_auth_required") == 0) {
strcmp(key, "nip42_auth_required") == 0 ||
strcmp(key, "nip17_admin_enabled") == 0) {
data_type = "boolean";
}
@@ -4446,13 +4939,13 @@ int process_startup_config_event(const cJSON* event) {
// Validate event structure first
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
if (!kind_obj || cJSON_GetNumberValue(kind_obj) != 33334) {
DEBUG_ERROR("Invalid event kind for startup configuration");
return -1;
}
cJSON* tags_obj = cJSON_GetObjectItem(event, "tags");
cJSON* tags_obj = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags_obj || !cJSON_IsArray(tags_obj)) {
DEBUG_ERROR("Startup configuration event missing tags");
return -1;
@@ -4648,7 +5141,7 @@ int req_filter_requests_config_events(const cJSON* filter) {
return 0;
}
cJSON* kinds = cJSON_GetObjectItem(filter, "kinds");
cJSON* kinds = cJSON_GetObjectItemCaseSensitive(filter, "kinds");
if (!kinds || !cJSON_IsArray(kinds)) {
return 0;
}
+4 -1
View File
@@ -116,7 +116,10 @@ cJSON* build_query_response(const char* query_type, cJSON* results_array, int to
int add_auth_rule_from_config(const char* rule_type, const char* pattern_type,
const char* pattern_value);
int remove_auth_rule_from_config(const char* rule_type, const char* pattern_type,
const char* pattern_value);
const char* pattern_value);
// Web of Trust (WoT) sync function
int wot_sync_from_admin_kind3(void);
// Unified configuration cache management
void force_config_cache_refresh(void);
+30 -1
View File
@@ -83,11 +83,40 @@ static const struct {
// IP-based rate limiting or access control (which would require firewall protection anyway)
{"trust_proxy_headers", "true"},
// IP Auth Failure Ban Settings
// Ban IPs that repeatedly fail NIP-42 authentication
{"auth_fail_ban_enabled", "true"},
{"auth_fail_ban_threshold", "3"}, // failures before ban
{"auth_fail_window_sec", "60"}, // window to count failures in
{"auth_fail_ban_duration_sec", "300"}, // initial ban duration (doubles each time, max 24h)
// NIP-42 Authentication Timeout
// Seconds after connection before unauthenticated clients are disconnected (0 = disabled)
// Prevents unauthenticated connections from accumulating under heavy load
{"nip42_auth_timeout_sec", "10"},
// SQLite Performance Tuning
// mmap_size: bytes of database file to memory-map (0 = disabled, 268435456 = 256MB recommended)
// Eliminates pread64 syscall overhead for database reads — significant CPU savings under load
{"sqlite_mmap_size", "268435456"},
// cache_size_kb: SQLite page cache size in KB (negative = KB, positive = pages of 4KB each)
// Default 2000KB is too small for a busy relay; 65536KB (64MB) keeps hot data in memory
{"sqlite_cache_size_kb", "65536"},
// NIP-59 Gift Wrap Timestamp Configuration
{"nip59_timestamp_max_delay_sec", "0"},
// Kind 1 Status Posts
{"kind_1_status_posts_hours", "1"}
{"kind_1_status_posts_hours", "1"},
// Web of Trust Settings
// 0 = off, 1 = write-only (followed pubkeys can publish), 2 = full (followed pubkeys can publish AND subscribe)
{"wot_enabled", "0"},
// NIP-17 Admin DM Settings
// When false (default), Kind 1059 gift wrap events are stored normally without expensive decryption attempts.
// Enable only if you intend to use NIP-17 DMs to send admin commands to the relay.
{"nip17_admin_enabled", "false"}
};
// Number of default configuration values
+162 -10
View File
@@ -37,10 +37,13 @@ extern const char* get_tag_value(cJSON* event, const char* tag_name, int value_i
extern char* get_relay_private_key(void);
extern const char* get_config_value(const char* key);
extern int get_config_bool(const char* key, int default_value);
extern int get_config_int(const char* key, int default_value);
extern int update_config_in_table(const char* key, const char* value);
// Forward declarations for database functions
extern int store_event(cJSON* event);
extern int broadcast_event_to_subscriptions(cJSON* event);
extern int store_event_tags(const char* event_id, cJSON* tags);
// Forward declarations for stats generation (moved to api.c)
extern char* generate_stats_json(void);
@@ -138,7 +141,7 @@ int process_dm_admin_command(cJSON* command_array, cJSON* event, char* error_mes
cJSON_AddItemToArray(synthetic_tags, command_tag);
// Add existing event tags
cJSON* existing_tags = cJSON_GetObjectItem(event, "tags");
cJSON* existing_tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (existing_tags && cJSON_IsArray(existing_tags)) {
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, existing_tags) {
@@ -301,7 +304,7 @@ cJSON* process_nip17_admin_message(cJSON* gift_wrap_event, char* error_message,
// Only create a generic response for other command types that don't handle their own responses
if (result == 0) {
// Extract content to check if it's a plain text command
cJSON* content_obj = cJSON_GetObjectItem(inner_dm, "content");
cJSON* content_obj = cJSON_GetObjectItemCaseSensitive(inner_dm, "content");
if (content_obj && cJSON_IsString(content_obj)) {
const char* dm_content = cJSON_GetStringValue(content_obj);
@@ -344,7 +347,7 @@ cJSON* process_nip17_admin_message(cJSON* gift_wrap_event, char* error_message,
return NULL;
// Get sender pubkey for response from the decrypted DM event
cJSON* sender_pubkey_obj = cJSON_GetObjectItem(inner_dm, "pubkey");
cJSON* sender_pubkey_obj = cJSON_GetObjectItemCaseSensitive(inner_dm, "pubkey");
if (sender_pubkey_obj && cJSON_IsString(sender_pubkey_obj)) {
const char* sender_pubkey = cJSON_GetStringValue(sender_pubkey_obj);
@@ -436,13 +439,13 @@ int is_nip17_gift_wrap_for_relay(cJSON* event) {
}
// Check kind
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
if (!kind_obj || !cJSON_IsNumber(kind_obj) || (int)cJSON_GetNumberValue(kind_obj) != 1059) {
return 0;
}
// Check tags for "p" tag with relay pubkey
cJSON* tags = cJSON_GetObjectItem(event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags || !cJSON_IsArray(tags)) {
return 0;
}
@@ -481,7 +484,7 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
DEBUG_INFO("DM_ADMIN: Processing NIP-17 admin command from decrypted DM");
// Extract content from DM
cJSON* content_obj = cJSON_GetObjectItem(dm_event, "content");
cJSON* content_obj = cJSON_GetObjectItemCaseSensitive(dm_event, "content");
if (!content_obj || !cJSON_IsString(content_obj)) {
DEBUG_INFO("DM_ADMIN: DM missing content field");
strncpy(error_message, "NIP-17: DM missing content", error_size - 1);
@@ -492,7 +495,7 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
DEBUG_INFO("DM_ADMIN: Extracted DM content: %.100s%s", dm_content, strlen(dm_content) > 100 ? "..." : "");
// Check if sender is admin before processing any commands
cJSON* sender_pubkey_obj = cJSON_GetObjectItem(dm_event, "pubkey");
cJSON* sender_pubkey_obj = cJSON_GetObjectItemCaseSensitive(dm_event, "pubkey");
if (!sender_pubkey_obj || !cJSON_IsString(sender_pubkey_obj)) {
DEBUG_INFO("DM_ADMIN: DM missing sender pubkey - treating as user DM");
return 0; // Not an error, just treat as user DM
@@ -598,6 +601,155 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
DEBUG_INFO("DM_ADMIN: Status command processed successfully");
return 0;
}
// Check for WoT (Web of Trust) commands
else if (strstr(content_lower, "wot") != NULL) {
DEBUG_INFO("DM_ADMIN: Processing WoT command");
// Skip "wot" prefix and find the subcommand
char* wot_cmd = strstr(content_lower, "wot");
if (wot_cmd) {
wot_cmd += 3; // Skip "wot"
while (*wot_cmd == ' ') wot_cmd++; // Skip spaces
char response_msg[1024];
int wot_level = get_config_int("wot_enabled", 0);
extern int wot_sync_from_admin_kind3(void);
if (strcmp(wot_cmd, "0") == 0 || strcmp(wot_cmd, "off") == 0) {
// Disable WoT
update_config_in_table("wot_enabled", "0");
update_config_in_table("auth_enabled", "false");
update_config_in_table("nip42_auth_required_subscriptions", "false");
// Clear wot_whitelist rules
const char* delete_sql = "DELETE FROM auth_rules WHERE rule_type = 'wot_whitelist'";
sqlite3_exec(g_db, delete_sql, NULL, NULL, NULL);
snprintf(response_msg, sizeof(response_msg),
"🔓 Web of Trust Disabled\n"
"━━━━━━━━━━━━━━━━━━━━━━\n"
"\n"
"WoT has been turned off.\n"
"\n"
"The relay is now open to all pubkeys for reading and writing.\n"
"Manual whitelist/blacklist rules are preserved.");
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
DEBUG_INFO("DM_ADMIN: WoT disabled");
}
else if (strcmp(wot_cmd, "1") == 0 || strcmp(wot_cmd, "write") == 0) {
// Write-only mode
update_config_in_table("wot_enabled", "1");
wot_sync_from_admin_kind3();
wot_level = get_config_int("wot_enabled", 0);
snprintf(response_msg, sizeof(response_msg),
"✍️ Web of Trust: Write-Only Mode\n"
"━━━━━━━━━━━━━━━━━━━━━━\n"
"\n"
"Level: 1 (Write-only restriction)\n"
"\n"
"Only pubkeys you follow can publish events.\n"
"Anyone can still subscribe and read events.\n"
"\n"
"The relay will now sync from your kind 3 (contact list) event.");
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
DEBUG_INFO("DM_ADMIN: WoT set to write-only mode");
}
else if (strcmp(wot_cmd, "2") == 0 || strcmp(wot_cmd, "full") == 0) {
// Full mode (write + read restriction)
update_config_in_table("wot_enabled", "2");
wot_sync_from_admin_kind3();
wot_level = get_config_int("wot_enabled", 0);
snprintf(response_msg, sizeof(response_msg),
"🔒 Web of Trust: Full Mode\n"
"━━━━━━━━━━━━━━━━━━━━━━\n"
"\n"
"Level: 2 (Full restriction)\n"
"\n"
"Only pubkeys you follow can:\n"
" • Publish events\n"
" • Subscribe to events (requires NIP-42 auth)\n"
"\n"
"This eliminates anonymous subscription churn.\n"
"\n"
"The relay will now sync from your kind 3 (contact list) event.");
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
DEBUG_INFO("DM_ADMIN: WoT set to full mode");
}
else if (strcmp(wot_cmd, "sync") == 0) {
// Force resync
wot_sync_from_admin_kind3();
snprintf(response_msg, sizeof(response_msg),
"🔄 Web of Trust: Sync Complete\n"
"━━━━━━━━━━━━━━━━━━━━━━\n"
"\n"
"WoT whitelist has been refreshed from your\n"
"kind 3 (contact list) event.\n"
"\n"
"Current level: %d", wot_level);
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
DEBUG_INFO("DM_ADMIN: WoT manual sync completed");
}
else if (strcmp(wot_cmd, "status") == 0 || strlen(wot_cmd) == 0) {
// Show status
// Count whitelisted pubkeys
sqlite3_stmt* stmt;
const char* count_sql = "SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'wot_whitelist' AND active = 1";
int whitelist_count = 0;
if (sqlite3_prepare_v2(g_db, count_sql, -1, &stmt, NULL) == SQLITE_OK) {
if (sqlite3_step(stmt) == SQLITE_ROW) {
whitelist_count = sqlite3_column_int(stmt, 0);
}
sqlite3_finalize(stmt);
}
const char* level_str;
if (wot_level == 0) level_str = "Off (open relay)";
else if (wot_level == 1) level_str = "Write-only (followed pubkeys can publish)";
else if (wot_level == 2) level_str = "Full (followed pubkeys can publish AND subscribe)";
else level_str = "Unknown";
snprintf(response_msg, sizeof(response_msg),
"📊 Web of Trust Status\n"
"━━━━━━━━━━━━━━━━━━━━━━\n"
"\n"
"Level: %d\n"
"%s\n"
"\n"
"Whitelisted pubkeys: %d\n"
"\n"
"Commands:\n"
" wot 0/off - Disable WoT\n"
" wot 1/write - Write-only mode\n"
" wot 2/full - Full restriction mode\n"
" wot sync - Force resync from kind 3\n"
" wot status - Show this status",
wot_level, level_str, whitelist_count);
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
DEBUG_INFO("DM_ADMIN: WoT status displayed");
}
else {
// Unknown wot subcommand
snprintf(response_msg, sizeof(response_msg),
"❌ Unknown WoT Command\n"
"━━━━━━━━━━━━━━━━━━━━━━\n"
"\n"
"Usage: wot [command]\n"
"\n"
"Commands:\n"
" 0, off - Disable WoT\n"
" 1, write - Write-only mode\n"
" 2, full - Full restriction mode\n"
" sync - Force resync from kind 3\n"
" status - Show current status");
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
}
}
return 0;
}
else {
DEBUG_INFO("DM_ADMIN: Checking for confirmation or config change requests");
// Check if it's a confirmation response (yes/no)
@@ -652,7 +804,7 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
if (cJSON_IsString(first_item) && strcmp(cJSON_GetStringValue(first_item), "stats") == 0) {
DEBUG_INFO("DM_ADMIN: Processing JSON stats command");
// Get sender pubkey for response
cJSON* sender_pubkey_obj = cJSON_GetObjectItem(dm_event, "pubkey");
cJSON* sender_pubkey_obj = cJSON_GetObjectItemCaseSensitive(dm_event, "pubkey");
if (!sender_pubkey_obj || !cJSON_IsString(sender_pubkey_obj)) {
cJSON_Delete(command_array);
DEBUG_INFO("DM_ADMIN: DM missing sender pubkey for stats command");
@@ -694,13 +846,13 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
cJSON_AddStringToObject(synthetic_event, "content", dm_content);
// Copy pubkey from DM
cJSON* pubkey_obj = cJSON_GetObjectItem(dm_event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(dm_event, "pubkey");
if (pubkey_obj && cJSON_IsString(pubkey_obj)) {
cJSON_AddStringToObject(synthetic_event, "pubkey", cJSON_GetStringValue(pubkey_obj));
}
// Copy tags from DM
cJSON* tags = cJSON_GetObjectItem(dm_event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(dm_event, "tags");
if (tags) {
cJSON_AddItemToObject(synthetic_event, "tags", cJSON_Duplicate(tags, 1));
}
File diff suppressed because one or more lines are too long
+276
View File
@@ -0,0 +1,276 @@
#define _GNU_SOURCE
#include "ip_ban.h"
#include "debug.h"
#include "config.h"
#include <string.h>
#include <stdlib.h>
#include <pthread.h>
// ============================================================
// IP Auth Failure Ban System
//
// Fixed-size open-addressing hash table. No malloc after init.
// Thread-safe via a single mutex (low contention — only called
// at connection open/close, not in the hot event path).
// ============================================================
#define IP_BAN_EMPTY 0 // slot is unused
#define IP_BAN_ACTIVE 1 // slot has an entry
typedef struct {
int state; // IP_BAN_EMPTY or IP_BAN_ACTIVE
char ip[46]; // IPv4 or IPv6 string
int failure_count; // total failures in current window
time_t first_failure; // start of current failure window
time_t banned_until; // 0 = not banned; >0 = banned until this time
int ban_count; // how many times this IP has been banned (for backoff)
} ip_ban_entry_t;
static ip_ban_entry_t g_ban_table[IP_BAN_TABLE_SIZE];
static pthread_mutex_t g_ban_mutex = PTHREAD_MUTEX_INITIALIZER;
static int g_initialized = 0;
// Simple FNV-1a hash for IP strings
static unsigned int ip_hash(const char* ip) {
unsigned int hash = 2166136261u;
while (*ip) {
hash ^= (unsigned char)*ip++;
hash *= 16777619u;
}
return hash % IP_BAN_TABLE_SIZE;
}
// Find slot for IP (open addressing with linear probing)
// Returns index of existing entry or first empty slot, -1 if table full
static int find_slot(const char* ip) {
unsigned int start = ip_hash(ip);
for (unsigned int i = 0; i < IP_BAN_TABLE_SIZE; i++) {
unsigned int idx = (start + i) % IP_BAN_TABLE_SIZE;
if (g_ban_table[idx].state == IP_BAN_EMPTY) {
return (int)idx; // empty slot — can insert here
}
if (strcmp(g_ban_table[idx].ip, ip) == 0) {
return (int)idx; // found existing entry
}
}
return -1; // table full (shouldn't happen with 4096 slots)
}
void ip_ban_init(void) {
pthread_mutex_lock(&g_ban_mutex);
memset(g_ban_table, 0, sizeof(g_ban_table));
g_initialized = 1;
pthread_mutex_unlock(&g_ban_mutex);
DEBUG_LOG("IP ban table initialized (%d slots)", IP_BAN_TABLE_SIZE);
}
int ip_ban_is_banned(const char* ip) {
if (!ip || !g_initialized) return 0;
// Check if feature is enabled
if (!get_config_bool("auth_fail_ban_enabled", 1)) return 0;
pthread_mutex_lock(&g_ban_mutex);
int idx = find_slot(ip);
if (idx < 0 || g_ban_table[idx].state == IP_BAN_EMPTY) {
pthread_mutex_unlock(&g_ban_mutex);
return 0; // no entry — not banned
}
ip_ban_entry_t* entry = &g_ban_table[idx];
time_t now = time(NULL);
if (entry->banned_until > 0 && now < entry->banned_until) {
pthread_mutex_unlock(&g_ban_mutex);
DEBUG_TRACE("IP %s is banned for %ld more seconds", ip, entry->banned_until - now);
return 1; // still banned
}
// Ban expired — clear it but keep the entry for failure tracking
if (entry->banned_until > 0 && now >= entry->banned_until) {
entry->banned_until = 0;
entry->failure_count = 0;
entry->first_failure = 0;
}
pthread_mutex_unlock(&g_ban_mutex);
return 0;
}
void ip_ban_record_failure(const char* ip) {
if (!ip || !g_initialized) return;
if (!get_config_bool("auth_fail_ban_enabled", 1)) return;
int threshold = get_config_int("auth_fail_ban_threshold", 3);
int window_sec = get_config_int("auth_fail_window_sec", 60);
int ban_duration = get_config_int("auth_fail_ban_duration_sec", 300);
pthread_mutex_lock(&g_ban_mutex);
int idx = find_slot(ip);
if (idx < 0) {
// Table full — can't track this IP, just log and return
DEBUG_WARN("IP ban table full, cannot track %s", ip);
pthread_mutex_unlock(&g_ban_mutex);
return;
}
ip_ban_entry_t* entry = &g_ban_table[idx];
time_t now = time(NULL);
if (entry->state == IP_BAN_EMPTY) {
// New entry
entry->state = IP_BAN_ACTIVE;
strncpy(entry->ip, ip, sizeof(entry->ip) - 1);
entry->ip[sizeof(entry->ip) - 1] = '\0';
entry->failure_count = 0;
entry->first_failure = now;
entry->banned_until = 0;
entry->ban_count = 0;
}
// Reset window if it's expired
if (entry->first_failure > 0 && (now - entry->first_failure) > window_sec) {
entry->failure_count = 0;
entry->first_failure = now;
}
entry->failure_count++;
DEBUG_TRACE("IP %s auth failure count: %d/%d", ip, entry->failure_count, threshold);
if (entry->failure_count >= threshold) {
// Apply exponential backoff: ban_duration * 2^ban_count, capped at 24 hours
int duration = ban_duration;
for (int i = 0; i < entry->ban_count && duration < 86400; i++) {
duration *= 2;
}
if (duration > 86400) duration = 86400;
entry->banned_until = now + duration;
entry->ban_count++;
entry->failure_count = 0; // reset counter after ban
entry->first_failure = 0;
DEBUG_WARN("IP %s banned for %d seconds (ban #%d) after %d auth failures",
ip, duration, entry->ban_count, threshold);
}
pthread_mutex_unlock(&g_ban_mutex);
}
void ip_ban_record_success(const char* ip) {
if (!ip || !g_initialized) return;
pthread_mutex_lock(&g_ban_mutex);
int idx = find_slot(ip);
if (idx >= 0 && g_ban_table[idx].state == IP_BAN_ACTIVE) {
// Clear failure count on successful auth — reward good behavior
g_ban_table[idx].failure_count = 0;
g_ban_table[idx].first_failure = 0;
// Note: we keep ban_count so backoff persists across sessions
DEBUG_TRACE("IP %s authenticated successfully — failure count cleared", ip);
}
pthread_mutex_unlock(&g_ban_mutex);
}
void ip_ban_cleanup(void) {
if (!g_initialized) return;
pthread_mutex_lock(&g_ban_mutex);
time_t now = time(NULL);
int window_sec = get_config_int("auth_fail_window_sec", 60);
int cleaned = 0;
for (int i = 0; i < IP_BAN_TABLE_SIZE; i++) {
if (g_ban_table[i].state != IP_BAN_ACTIVE) continue;
ip_ban_entry_t* entry = &g_ban_table[i];
// Clear entries where ban has expired AND failure window has expired AND no recent activity
int ban_expired = (entry->banned_until == 0 || now >= entry->banned_until);
int window_expired = (entry->first_failure == 0 || (now - entry->first_failure) > window_sec * 10);
if (ban_expired && window_expired && entry->failure_count == 0) {
memset(entry, 0, sizeof(ip_ban_entry_t));
cleaned++;
}
}
if (cleaned > 0) {
DEBUG_TRACE("IP ban cleanup: freed %d stale entries", cleaned);
}
pthread_mutex_unlock(&g_ban_mutex);
}
int ip_ban_get_banned_count(void) {
if (!g_initialized) return 0;
pthread_mutex_lock(&g_ban_mutex);
time_t now = time(NULL);
int count = 0;
for (int i = 0; i < IP_BAN_TABLE_SIZE; i++) {
if (g_ban_table[i].state == IP_BAN_ACTIVE &&
g_ban_table[i].banned_until > now) {
count++;
}
}
pthread_mutex_unlock(&g_ban_mutex);
return count;
}
int ip_ban_get_tracked_count(void) {
if (!g_initialized) return 0;
pthread_mutex_lock(&g_ban_mutex);
int count = 0;
for (int i = 0; i < IP_BAN_TABLE_SIZE; i++) {
if (g_ban_table[i].state == IP_BAN_ACTIVE) count++;
}
pthread_mutex_unlock(&g_ban_mutex);
return count;
}
// Emit a periodic log summary of banned IPs.
// Call from the connection age checker (runs every ~30s).
// Only logs when there are active bans or when the interval has elapsed.
void ip_ban_log_stats(void) {
if (!g_initialized) return;
static time_t last_log = 0;
time_t now = time(NULL);
// Log every 5 minutes
if (now - last_log < 300) return;
last_log = now;
pthread_mutex_lock(&g_ban_mutex);
int banned_count = 0;
int tracked_count = 0;
for (int i = 0; i < IP_BAN_TABLE_SIZE; i++) {
if (g_ban_table[i].state != IP_BAN_ACTIVE) continue;
tracked_count++;
if (g_ban_table[i].banned_until > now) {
banned_count++;
DEBUG_WARN("IP BAN: %s banned for %ld more seconds (ban #%d, failures: %d)",
g_ban_table[i].ip,
g_ban_table[i].banned_until - now,
g_ban_table[i].ban_count,
g_ban_table[i].failure_count);
}
}
pthread_mutex_unlock(&g_ban_mutex);
if (banned_count > 0 || tracked_count > 0) {
DEBUG_WARN("IP BAN SUMMARY: %d IPs currently banned, %d IPs tracked",
banned_count, tracked_count);
}
}
+48
View File
@@ -0,0 +1,48 @@
#ifndef IP_BAN_H
#define IP_BAN_H
// IP Auth Failure Ban System
//
// Tracks auth failures per IP address and temporarily bans IPs that repeatedly
// fail NIP-42 authentication. Uses an in-memory fixed-size hash table — no
// database writes, no root required, no persistent state.
//
// Config keys (all read from the config table at runtime):
// auth_fail_ban_enabled bool default: true (0 = disabled)
// auth_fail_ban_threshold int default: 3 (failures before ban)
// auth_fail_window_sec int default: 60 (window to count failures)
// auth_fail_ban_duration_sec int default: 300 (initial ban duration, doubles each time)
#include <time.h>
// Maximum number of IPs tracked simultaneously (fixed-size, no malloc)
#define IP_BAN_TABLE_SIZE 4096
// Initialize the IP ban table (call once at startup)
void ip_ban_init(void);
// Check if an IP is currently banned.
// Returns 1 if banned (connection should be rejected), 0 if allowed.
int ip_ban_is_banned(const char* ip);
// Record an auth failure for an IP.
// Called when a connection is closed due to auth timeout.
// May trigger a ban if the threshold is exceeded.
void ip_ban_record_failure(const char* ip);
// Record a successful auth for an IP.
// Clears any failure count for this IP (reward good behavior).
void ip_ban_record_success(const char* ip);
// Periodic cleanup: expire old entries (call from the connection age checker).
void ip_ban_cleanup(void);
// Get stats for logging/monitoring
int ip_ban_get_banned_count(void);
int ip_ban_get_tracked_count(void);
// Emit a periodic WARN-level log summary of banned IPs (every 5 minutes).
// Call from the connection age checker timer.
void ip_ban_log_stats(void);
#endif // IP_BAN_H
+304 -65
View File
@@ -162,6 +162,9 @@ int handle_nip11_http_request(struct lws* wsi, const char* accept_header);
// Forward declaration for WebSocket relay server
int start_websocket_relay(int port_override, int strict_port);
// Forward declaration for IP ban system
void ip_ban_init(void);
// Forward declarations for NIP-13 PoW handling (now in nip013.c)
void init_pow_config();
@@ -566,6 +569,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;
}
@@ -657,6 +694,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) {
@@ -665,13 +742,13 @@ int store_event(cJSON* event) {
}
// Extract event fields
cJSON* id = cJSON_GetObjectItem(event, "id");
cJSON* pubkey = cJSON_GetObjectItem(event, "pubkey");
cJSON* created_at = cJSON_GetObjectItem(event, "created_at");
cJSON* kind = cJSON_GetObjectItem(event, "kind");
cJSON* content = cJSON_GetObjectItem(event, "content");
cJSON* sig = cJSON_GetObjectItem(event, "sig");
cJSON* tags = cJSON_GetObjectItem(event, "tags");
cJSON* id = cJSON_GetObjectItemCaseSensitive(event, "id");
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* content = cJSON_GetObjectItemCaseSensitive(event, "content");
cJSON* sig = cJSON_GetObjectItemCaseSensitive(event, "sig");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!id || !pubkey || !created_at || !kind || !content || !sig) {
DEBUG_ERROR("Invalid event - missing required fields");
@@ -738,7 +815,9 @@ int store_event(cJSON* event) {
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);
@@ -783,6 +862,87 @@ int store_event(cJSON* event) {
// Call monitoring hook after successful event storage
monitoring_on_event_stored();
// After successful event storage, insert denormalized tags
store_event_tags(cJSON_GetStringValue(id), tags);
// Check if this is a kind 3 event from the admin — trigger WoT sync
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (kind_obj && pubkey_obj && cJSON_GetNumberValue(kind_obj) == 3) {
int wot_level = get_config_int("wot_enabled", 0);
if (wot_level > 0) {
const char* admin_pubkey = get_config_value("admin_pubkey");
if (admin_pubkey && strcmp(cJSON_GetStringValue(pubkey_obj), admin_pubkey) == 0) {
DEBUG_INFO("Admin kind 3 event stored — triggering WoT sync");
extern int wot_sync_from_admin_kind3(void);
wot_sync_from_admin_kind3();
}
if (admin_pubkey) free((char*)admin_pubkey);
}
}
return 0;
}
// Fast duplicate check: returns 1 if event ID already exists in DB, 0 if not.
// Uses the primary key index — single B-tree lookup, ~10μs.
// Call this BEFORE signature verification to skip expensive crypto on duplicates.
int event_id_exists_in_db(const char* event_id) {
if (!g_db || !event_id || strlen(event_id) != 64) return 0;
sqlite3_stmt* stmt;
const char* sql = "SELECT 1 FROM events WHERE id=? LIMIT 1";
if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL) != SQLITE_OK) return 0;
sqlite3_bind_text(stmt, 1, event_id, 64, SQLITE_STATIC);
int exists = (sqlite3_step(stmt) == SQLITE_ROW) ? 1 : 0;
sqlite3_finalize(stmt);
return exists;
}
// Populate event_tags from existing events (run once at startup)
int populate_event_tags_from_existing(void) {
if (!g_db) return -1;
// Check if event_tags is already populated
sqlite3_stmt* check_stmt;
sqlite3_prepare_v2(g_db, "SELECT COUNT(*) FROM event_tags", -1, &check_stmt, NULL);
if (sqlite3_step(check_stmt) == SQLITE_ROW && sqlite3_column_int(check_stmt, 0) > 0) {
sqlite3_finalize(check_stmt);
DEBUG_INFO("event_tags already populated, skipping");
return 0;
}
sqlite3_finalize(check_stmt);
DEBUG_INFO("Populating event_tags from existing events...");
const char* sql = "SELECT id, tags FROM events WHERE tags != '[]'";
sqlite3_stmt* stmt;
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) return -1;
// Use a transaction for bulk insert performance
sqlite3_exec(g_db, "BEGIN TRANSACTION", NULL, NULL, NULL);
int event_count = 0;
while (sqlite3_step(stmt) == SQLITE_ROW) {
const char* event_id = (const char*)sqlite3_column_text(stmt, 0);
const char* tags_json = (const char*)sqlite3_column_text(stmt, 1);
if (event_id && tags_json) {
cJSON* tags = cJSON_Parse(tags_json);
if (tags) {
store_event_tags(event_id, tags);
cJSON_Delete(tags);
event_count++;
}
}
}
sqlite3_finalize(stmt);
sqlite3_exec(g_db, "COMMIT", NULL, NULL, NULL);
DEBUG_INFO("Populated event_tags for %d events", event_count);
return 0;
}
@@ -844,58 +1004,117 @@ cJSON* retrieve_event(const char* event_id) {
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
/**
* Check if filters contain only kind 99999 (NDK ping)
* Returns 1 if all filters only request kind 99999, 0 otherwise
*/
static int is_only_kind_99999_request(cJSON* filters) {
if (!filters || !cJSON_IsArray(filters)) {
return 0;
}
int filter_count = cJSON_GetArraySize(filters);
if (filter_count == 0) {
return 0;
}
for (int i = 0; i < filter_count; i++) {
cJSON* filter = cJSON_GetArrayItem(filters, i);
if (!filter || !cJSON_IsObject(filter)) {
return 0;
}
cJSON* kinds = cJSON_GetObjectItemCaseSensitive(filter, "kinds");
if (!kinds || !cJSON_IsArray(kinds)) {
// Filter has no kinds or kinds is not an array - not a pure 99999 request
return 0;
}
int kinds_count = cJSON_GetArraySize(kinds);
if (kinds_count == 0) {
return 0;
}
for (int j = 0; j < kinds_count; j++) {
cJSON* kind_item = cJSON_GetArrayItem(kinds, j);
if (!cJSON_IsNumber(kind_item)) {
return 0;
}
int kind_val = (int)cJSON_GetNumberValue(kind_item);
if (kind_val != 99999) {
return 0;
}
}
}
return 1; // All filters only contain kind 99999
}
int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, struct per_session_data *pss) {
if (!cJSON_IsArray(filters)) {
DEBUG_ERROR("REQ filters is not an array");
return 0;
}
// Check if this is a kind 99999 (NDK ping) request - these should never count toward rate limiting
int is_ndk_ping = is_only_kind_99999_request(filters);
// EARLY SUBSCRIPTION LIMIT CHECK - Check limits BEFORE any processing
if (pss) {
time_t current_time = time(NULL);
// Check if client is currently rate limited due to excessive failed attempts
if (pss->rate_limit_until > current_time) {
char rate_limit_msg[256];
int remaining_seconds = (int)(pss->rate_limit_until - current_time);
snprintf(rate_limit_msg, sizeof(rate_limit_msg),
"Rate limited due to excessive failed subscription attempts. Try again in %d seconds.", remaining_seconds);
// NDK ping (kind 99999) should not be blocked by rate limiting at all
if (is_ndk_ping) {
// Allow the request through - it will be handled normally and get a proper response
// The subscription will be created but no events will match (which is correct)
DEBUG_TRACE("Allowing kind 99999 NDK ping through despite rate limit");
// Fall through to normal processing (skip rate limit block)
} else {
char rate_limit_msg[256];
int remaining_seconds = (int)(pss->rate_limit_until - current_time);
snprintf(rate_limit_msg, sizeof(rate_limit_msg),
"Rate limited due to excessive failed subscription attempts. Try again in %d seconds.", remaining_seconds);
// Send CLOSED notice for rate limiting
cJSON* closed_msg = cJSON_CreateArray();
cJSON_AddItemToArray(closed_msg, cJSON_CreateString("CLOSED"));
cJSON_AddItemToArray(closed_msg, cJSON_CreateString(sub_id));
cJSON_AddItemToArray(closed_msg, cJSON_CreateString("error: rate limited"));
cJSON_AddItemToArray(closed_msg, cJSON_CreateString(rate_limit_msg));
// Send CLOSED notice for rate limiting
cJSON* closed_msg = cJSON_CreateArray();
cJSON_AddItemToArray(closed_msg, cJSON_CreateString("CLOSED"));
cJSON_AddItemToArray(closed_msg, cJSON_CreateString(sub_id));
cJSON_AddItemToArray(closed_msg, cJSON_CreateString("error: rate limited"));
cJSON_AddItemToArray(closed_msg, cJSON_CreateString(rate_limit_msg));
char* closed_str = cJSON_Print(closed_msg);
if (closed_str) {
size_t closed_len = strlen(closed_str);
unsigned char* buf = malloc(LWS_PRE + closed_len);
if (buf) {
memcpy(buf + LWS_PRE, closed_str, closed_len);
lws_write(wsi, buf + LWS_PRE, closed_len, LWS_WRITE_TEXT);
free(buf);
char* closed_str = cJSON_Print(closed_msg);
if (closed_str) {
size_t closed_len = strlen(closed_str);
unsigned char* buf = malloc(LWS_PRE + closed_len);
if (buf) {
memcpy(buf + LWS_PRE, closed_str, closed_len);
lws_write(wsi, buf + LWS_PRE, closed_len, LWS_WRITE_TEXT);
free(buf);
}
free(closed_str);
}
free(closed_str);
cJSON_Delete(closed_msg);
// Do NOT increment rate limiting counters here - the client is already rate limited.
// Incrementing counters while already blocked would extend the punishment indefinitely,
// especially for benign requests like NDK's kind 99999 pings.
return 0;
}
cJSON_Delete(closed_msg);
// Update rate limiting counters
pss->failed_subscription_attempts++;
pss->last_failed_attempt = current_time;
return 0;
}
// Check session subscription limits
if (pss->subscription_count >= g_subscription_manager.max_subscriptions_per_client) {
DEBUG_ERROR("Maximum subscriptions per client exceeded");
// Update rate limiting counters for failed attempt
pss->failed_subscription_attempts++;
pss->last_failed_attempt = current_time;
pss->consecutive_failures++;
// Update rate limiting counters for failed attempt (but not for NDK ping)
if (!is_ndk_ping) {
pss->failed_subscription_attempts++;
pss->last_failed_attempt = current_time;
pss->consecutive_failures++;
}
// Implement progressive backoff: 1s, 5s, 30s, 300s (5min) based on consecutive failures
int backoff_seconds = 1;
@@ -1010,7 +1229,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;
@@ -1055,7 +1275,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
// after retrieving events to ensure compatibility with all SQLite versions
// Handle kinds filter
cJSON* kinds = cJSON_GetObjectItem(filter, "kinds");
cJSON* kinds = cJSON_GetObjectItemCaseSensitive(filter, "kinds");
if (kinds && cJSON_IsArray(kinds)) {
int kind_count = cJSON_GetArraySize(kinds);
if (kind_count > 0) {
@@ -1083,7 +1303,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
// Handle authors filter
cJSON* authors = cJSON_GetObjectItem(filter, "authors");
cJSON* authors = cJSON_GetObjectItemCaseSensitive(filter, "authors");
if (authors && cJSON_IsArray(authors)) {
int author_count = 0;
// Count valid authors
@@ -1123,7 +1343,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
// Handle ids filter
cJSON* ids = cJSON_GetObjectItem(filter, "ids");
cJSON* ids = cJSON_GetObjectItemCaseSensitive(filter, "ids");
if (ids && cJSON_IsArray(ids)) {
int id_count = 0;
// Count valid ids
@@ -1180,8 +1400,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);
@@ -1213,7 +1433,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
// Handle search filter (NIP-50)
cJSON* search = cJSON_GetObjectItem(filter, "search");
cJSON* search = cJSON_GetObjectItemCaseSensitive(filter, "search");
if (search && cJSON_IsString(search)) {
const char* search_term = cJSON_GetStringValue(search);
if (search_term && strlen(search_term) > 0) {
@@ -1241,7 +1461,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
// Handle since filter
cJSON* since = cJSON_GetObjectItem(filter, "since");
cJSON* since = cJSON_GetObjectItemCaseSensitive(filter, "since");
if (since && cJSON_IsNumber(since)) {
snprintf(sql_ptr, remaining, " AND created_at >= %ld", (long)cJSON_GetNumberValue(since));
sql_ptr += strlen(sql_ptr);
@@ -1249,7 +1469,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
// Handle until filter
cJSON* until = cJSON_GetObjectItem(filter, "until");
cJSON* until = cJSON_GetObjectItemCaseSensitive(filter, "until");
if (until && cJSON_IsNumber(until)) {
snprintf(sql_ptr, remaining, " AND created_at <= %ld", (long)cJSON_GetNumberValue(until));
sql_ptr += strlen(sql_ptr);
@@ -1262,7 +1482,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
remaining = sizeof(sql) - strlen(sql);
// Handle limit filter
cJSON* limit = cJSON_GetObjectItem(filter, "limit");
cJSON* limit = cJSON_GetObjectItemCaseSensitive(filter, "limit");
if (limit && cJSON_IsNumber(limit)) {
int limit_val = (int)cJSON_GetNumberValue(limit);
if (limit_val > 0 && limit_val <= 5000) {
@@ -1312,7 +1532,11 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
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++;
@@ -1337,9 +1561,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
// 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)) {
@@ -1349,21 +1571,24 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
}
// Build EVENT message using string concatenation (much faster than cJSON operations)
// Build EVENT message using zero-copy path: allocate with LWS_PRE prefix,
// write directly, transfer ownership to queue — no memcpy.
// Format: ["EVENT","<sub_id>",<event_json>]
size_t sub_id_len = strlen(sub_id);
size_t event_json_len = strlen(event_json_str);
size_t msg_len = 10 + sub_id_len + 3 + event_json_len + 1; // ["EVENT",""] + sub_id + "," + event_json + ]
char* msg_str = malloc(msg_len + 1);
if (msg_str) {
snprintf(msg_str, msg_len + 1, "[\"EVENT\",\"%s\",%s]", sub_id, event_json_str);
// Use proper message queue system instead of direct lws_write
if (queue_message(wsi, pss, msg_str, strlen(msg_str), LWS_WRITE_TEXT) != 0) {
size_t msg_len = 10 + sub_id_len + 3 + event_json_len + 1;
unsigned char* buf = malloc(LWS_PRE + msg_len + 1);
if (buf) {
char* msg_ptr = (char*)(buf + LWS_PRE);
snprintf(msg_ptr, msg_len + 1, "[\"EVENT\",\"%s\",%s]", sub_id, event_json_str);
size_t actual_len = strlen(msg_ptr);
// queue_message_take_ownership takes buf ownership — no memcpy, no free needed here
if (queue_message_take_ownership(wsi, pss, buf, actual_len, LWS_WRITE_TEXT) != 0) {
DEBUG_ERROR("Failed to queue EVENT message for sub=%s", sub_id);
// buf already freed by queue_message_take_ownership on failure
}
free(msg_str);
}
cJSON_Delete(event);
@@ -1403,7 +1628,7 @@ int is_authorized_admin_event(cJSON* event, char* error_buffer, size_t error_buf
// Step 1: Verify event kind is admin type
cJSON *kind_json = cJSON_GetObjectItem(event, "kind");
cJSON *kind_json = cJSON_GetObjectItemCaseSensitive(event, "kind");
if (!kind_json || !cJSON_IsNumber(kind_json)) {
snprintf(error_buffer, error_buffer_size, "Missing or invalid event kind");
return -1;
@@ -1416,7 +1641,7 @@ int is_authorized_admin_event(cJSON* event, char* error_buffer, size_t error_buf
}
// Step 2: Check if event targets this relay (look for 'p' tag with our relay pubkey)
cJSON *tags = cJSON_GetObjectItem(event, "tags");
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags || !cJSON_IsArray(tags)) {
// No tags array - treat as regular event for different relay
snprintf(error_buffer, error_buffer_size, "Admin event not targeting this relay (no tags)");
@@ -1451,7 +1676,7 @@ int is_authorized_admin_event(cJSON* event, char* error_buffer, size_t error_buf
}
// Step 3: Verify admin signature authorization
cJSON *pubkey_json = cJSON_GetObjectItem(event, "pubkey");
cJSON *pubkey_json = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (!pubkey_json || !cJSON_IsString(pubkey_json)) {
DEBUG_WARN("Unauthorized admin event attempt: missing or invalid pubkey");
snprintf(error_buffer, error_buffer_size, "Unauthorized admin event attempt: missing pubkey");
@@ -1929,9 +2154,23 @@ int main(int argc, char* argv[]) {
// Initialize kind-based index for fast subscription lookup
init_kind_index();
// Populate event_tags from existing events (for tag-based lookups)
populate_event_tags_from_existing();
// Sync Web of Trust whitelist if enabled
int wot_level = get_config_int("wot_enabled", 0);
if (wot_level > 0) {
DEBUG_INFO("WoT enabled (level %d) - syncing from admin's kind 3 event", wot_level);
extern int wot_sync_from_admin_kind3(void);
wot_sync_from_admin_kind3();
}
// Cleanup orphaned subscriptions from previous runs
cleanup_all_subscriptions_on_startup();
// Initialize IP ban table before starting the relay
ip_ban_init();
// Start WebSocket Nostr relay server (port from CLI override or configuration)
int result = start_websocket_relay(cli_options.port_override, cli_options.strict_port); // Use CLI port override if specified, otherwise config
+2 -2
View File
@@ -13,8 +13,8 @@
// Using CRELAY_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define CRELAY_VERSION_MAJOR 1
#define CRELAY_VERSION_MINOR 2
#define CRELAY_VERSION_PATCH 1
#define CRELAY_VERSION "v1.2.1"
#define CRELAY_VERSION_PATCH 20
#define CRELAY_VERSION "v1.2.20"
// Relay metadata (authoritative source for NIP-11 information)
#define RELAY_NAME "C-Relay"
+6 -6
View File
@@ -33,12 +33,12 @@ int handle_deletion_request(cJSON* event, char* error_message, size_t error_size
}
// Extract event details
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* created_at_obj = cJSON_GetObjectItem(event, "created_at");
cJSON* tags_obj = cJSON_GetObjectItem(event, "tags");
cJSON* content_obj = cJSON_GetObjectItem(event, "content");
cJSON* event_id_obj = cJSON_GetObjectItem(event, "id");
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
cJSON* created_at_obj = cJSON_GetObjectItemCaseSensitive(event, "created_at");
cJSON* tags_obj = cJSON_GetObjectItemCaseSensitive(event, "tags");
cJSON* content_obj = cJSON_GetObjectItemCaseSensitive(event, "content");
cJSON* event_id_obj = cJSON_GetObjectItemCaseSensitive(event, "id");
if (!kind_obj || !pubkey_obj || !created_at_obj || !tags_obj || !event_id_obj) {
snprintf(error_message, error_size, "invalid: incomplete deletion request");
+23 -2
View File
@@ -118,6 +118,8 @@ cJSON* generate_relay_info_json() {
int default_limit = get_config_int("default_limit", 500);
int min_pow_difficulty = get_config_int("pow_min_difficulty", 0);
int admin_enabled = get_config_bool("admin_enabled", 0);
int wot_enabled = get_config_int("wot_enabled", 0);
int auth_enabled = get_config_bool("auth_enabled", 0);
// Add basic relay information
if (relay_name && strlen(relay_name) > 0) {
@@ -209,6 +211,9 @@ cJSON* generate_relay_info_json() {
}
// Add server limitations
// restricted_writes is true when WoT or auth is enabled (only trusted pubkeys can write)
int restricted_writes = (wot_enabled > 0 || auth_enabled) ? 1 : 0;
cJSON* limitation = cJSON_CreateObject();
if (limitation) {
cJSON_AddNumberToObject(limitation, "max_message_length", max_message_length);
@@ -218,15 +223,31 @@ cJSON* generate_relay_info_json() {
cJSON_AddNumberToObject(limitation, "max_event_tags", max_event_tags);
cJSON_AddNumberToObject(limitation, "max_content_length", max_content_length);
cJSON_AddNumberToObject(limitation, "min_pow_difficulty", min_pow_difficulty);
cJSON_AddBoolToObject(limitation, "auth_required", admin_enabled ? cJSON_True : cJSON_False);
cJSON_AddBoolToObject(limitation, "auth_required", auth_enabled ? cJSON_True : cJSON_False);
cJSON_AddBoolToObject(limitation, "payment_required", cJSON_False);
cJSON_AddBoolToObject(limitation, "restricted_writes", cJSON_False);
cJSON_AddBoolToObject(limitation, "restricted_writes", restricted_writes ? cJSON_True : cJSON_False);
cJSON_AddNumberToObject(limitation, "created_at_lower_limit", 0);
cJSON_AddNumberToObject(limitation, "created_at_upper_limit", 2147483647);
cJSON_AddNumberToObject(limitation, "default_limit", default_limit);
cJSON_AddItemToObject(info, "limitation", limitation);
}
// Add Web of Trust information when enabled
// This informs clients that the relay operates on a trust network
if (wot_enabled > 0) {
cJSON* wot_info = cJSON_CreateObject();
if (wot_info) {
cJSON_AddNumberToObject(wot_info, "level", wot_enabled);
const char* level_desc = (wot_enabled == 1)
? "write-only: only followed pubkeys can publish events"
: "full: only followed pubkeys can publish and subscribe";
cJSON_AddStringToObject(wot_info, "description", level_desc);
cJSON_AddStringToObject(wot_info, "policy",
"Events from pubkeys not in the relay operator's Web of Trust are rejected.");
cJSON_AddItemToObject(info, "web_of_trust", wot_info);
}
}
// Add retention policies (empty array for now)
cJSON* retention = cJSON_CreateArray();
if (retention) {
+1 -1
View File
@@ -55,7 +55,7 @@ int validate_event_pow(cJSON* event, char* error_message, size_t error_size) {
// If min_pow_difficulty is 0, only validate events that have nonce tags
// This allows events without PoW when difficulty requirement is 0
if (min_pow_difficulty == 0) {
cJSON* tags = cJSON_GetObjectItem(event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
int has_nonce_tag = 0;
if (tags && cJSON_IsArray(tags)) {
+2 -2
View File
@@ -114,7 +114,7 @@ int is_event_expired(cJSON* event, time_t current_time) {
return 0; // Invalid event, not expired
}
cJSON* tags = cJSON_GetObjectItem(event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
long expiration_ts = extract_expiration_timestamp(tags);
if (expiration_ts == 0) {
@@ -140,7 +140,7 @@ int validate_event_expiration(cJSON* event, char* error_message, size_t error_si
time_t current_time = time(NULL);
if (is_event_expired(event, current_time)) {
if (g_expiration_config.strict_mode) {
cJSON* tags = cJSON_GetObjectItem(event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
long expiration_ts = extract_expiration_timestamp(tags);
snprintf(error_message, error_size,
+4 -1
View File
@@ -99,7 +99,7 @@ void handle_nip42_auth_signed_event(struct lws* wsi, struct per_session_data* ps
char authenticated_pubkey[65] = {0};
if (result == 0) {
// Extract pubkey from the auth event
cJSON* pubkey_json = cJSON_GetObjectItem(auth_event, "pubkey");
cJSON* pubkey_json = cJSON_GetObjectItemCaseSensitive(auth_event, "pubkey");
if (pubkey_json && cJSON_IsString(pubkey_json)) {
const char* pubkey_str = cJSON_GetStringValue(pubkey_json);
if (pubkey_str && strlen(pubkey_str) == 64) {
@@ -126,6 +126,9 @@ void handle_nip42_auth_signed_event(struct lws* wsi, struct per_session_data* ps
pss->challenge_expires = 0;
pss->auth_challenge_sent = 0;
pthread_mutex_unlock(&pss->session_lock);
// Cancel the auth timeout — client has authenticated, keep connection open
lws_set_timeout(wsi, NO_PENDING_TIMEOUT, 0);
send_notice_message(wsi, pss, "NIP-42 authentication successful");
} else {
+15 -15
View File
@@ -252,13 +252,13 @@ int nostr_validate_unified_request(const char* json_string, size_t json_length)
}
// 4. Validate basic event structure
cJSON *id = cJSON_GetObjectItem(event, "id");
cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey");
cJSON *created_at = cJSON_GetObjectItem(event, "created_at");
cJSON *kind = cJSON_GetObjectItem(event, "kind");
cJSON *tags = cJSON_GetObjectItem(event, "tags");
cJSON *content = cJSON_GetObjectItem(event, "content");
cJSON *sig = cJSON_GetObjectItem(event, "sig");
cJSON *id = cJSON_GetObjectItemCaseSensitive(event, "id");
cJSON *pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
cJSON *created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
cJSON *kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content");
cJSON *sig = cJSON_GetObjectItemCaseSensitive(event, "sig");
if (!id || !cJSON_IsString(id) ||
!pubkey || !cJSON_IsString(pubkey) ||
@@ -380,7 +380,7 @@ int nostr_validate_unified_request(const char* json_string, size_t json_length)
// Always check expiration tags if present (following NIP-40 specification)
cJSON *expiration_tag = NULL;
cJSON *tags_array = cJSON_GetObjectItem(event, "tags");
cJSON *tags_array = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (tags_array && cJSON_IsArray(tags_array)) {
cJSON *tag = NULL;
@@ -595,10 +595,10 @@ int check_database_auth_rules(const char *pubkey, const char *operation __attrib
}
}
// Step 3: Check pubkey whitelist
// Step 3: Check pubkey whitelist (includes wot_whitelist)
const char *whitelist_sql =
"SELECT rule_type FROM auth_rules WHERE rule_type = "
"'whitelist' AND pattern_type = 'pubkey' AND pattern_value = ? AND active = 1 LIMIT 1";
"SELECT rule_type FROM auth_rules WHERE rule_type IN "
"('whitelist', 'wot_whitelist') AND pattern_type = 'pubkey' AND pattern_value = ? AND active = 1 LIMIT 1";
rc = sqlite3_prepare_v2(db, whitelist_sql, -1, &stmt, NULL);
if (rc == SQLITE_OK) {
sqlite3_bind_text(stmt, 1, pubkey, -1, SQLITE_STATIC);
@@ -611,9 +611,9 @@ int check_database_auth_rules(const char *pubkey, const char *operation __attrib
sqlite3_finalize(stmt);
}
// Step 4: Check if any whitelist rules exist - if yes, deny by default
// Step 4: Check if any whitelist rules exist - if yes, deny by default (includes wot_whitelist)
const char *whitelist_exists_sql =
"SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'whitelist' "
"SELECT COUNT(*) FROM auth_rules WHERE rule_type IN ('whitelist', 'wot_whitelist') "
"AND pattern_type = 'pubkey' AND active = 1 LIMIT 1";
rc = sqlite3_prepare_v2(db, whitelist_exists_sql, -1, &stmt, NULL);
if (rc == SQLITE_OK) {
@@ -818,7 +818,7 @@ int nostr_nip42_verify_auth_event(cJSON *event, const char *challenge_id,
}
// Check if event has the required tags for NIP-42
cJSON *tags = cJSON_GetObjectItem(event, "tags");
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags || !cJSON_IsArray(tags)) {
return NOSTR_ERROR_NIP42_AUTH_EVENT_INVALID;
}
@@ -896,7 +896,7 @@ int nostr_nip42_verify_auth_event(cJSON *event, const char *challenge_id,
}
// Check created_at timestamp for reasonable bounds
cJSON *created_at_json = cJSON_GetObjectItem(event, "created_at");
cJSON *created_at_json = cJSON_GetObjectItemCaseSensitive(event, "created_at");
if (created_at_json && cJSON_IsNumber(created_at_json)) {
time_t created_at = (time_t)cJSON_GetNumberValue(created_at_json);
if (abs((int)(now - created_at)) > time_tolerance_seconds) {
+26 -7
View File
@@ -1,11 +1,11 @@
/* Embedded SQL Schema for C Nostr Relay
* Schema Version: 11
* Schema Version: 12
*/
#ifndef SQL_SCHEMA_H
#define SQL_SCHEMA_H
/* Schema version constant */
#define EMBEDDED_SCHEMA_VERSION "11"
#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 = 11;\n\
PRAGMA user_version = 12;\n\
\n\
-- Enable foreign key support\n\
PRAGMA foreign_keys = ON;\n\
@@ -49,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\
@@ -58,8 +77,8 @@ CREATE TABLE schema_info (\n\
\n\
-- Insert schema metadata\n\
INSERT INTO schema_info (key, value) VALUES\n\
('version', '11'),\n\
('description', 'Added event_json column for 2500x performance improvement in event retrieval'),\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\
@@ -129,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\
@@ -311,4 +330,4 @@ SELECT\n\
FROM events\n\
WHERE created_at >= (strftime('%s', 'now') - 2592000);";
#endif /* SQL_SCHEMA_H */
#endif /* SQL_SCHEMA_H */
+67 -62
View File
@@ -192,32 +192,32 @@ subscription_filter_t* create_subscription_filter(cJSON* filter_json) {
}
// Copy filter criteria
cJSON* kinds = cJSON_GetObjectItem(filter_json, "kinds");
cJSON* kinds = cJSON_GetObjectItemCaseSensitive(filter_json, "kinds");
if (kinds && cJSON_IsArray(kinds)) {
filter->kinds = cJSON_Duplicate(kinds, 1);
}
cJSON* authors = cJSON_GetObjectItem(filter_json, "authors");
cJSON* authors = cJSON_GetObjectItemCaseSensitive(filter_json, "authors");
if (authors && cJSON_IsArray(authors)) {
filter->authors = cJSON_Duplicate(authors, 1);
}
cJSON* ids = cJSON_GetObjectItem(filter_json, "ids");
cJSON* ids = cJSON_GetObjectItemCaseSensitive(filter_json, "ids");
if (ids && cJSON_IsArray(ids)) {
filter->ids = cJSON_Duplicate(ids, 1);
}
cJSON* since = cJSON_GetObjectItem(filter_json, "since");
cJSON* since = cJSON_GetObjectItemCaseSensitive(filter_json, "since");
if (since && cJSON_IsNumber(since)) {
filter->since = (long)cJSON_GetNumberValue(since);
}
cJSON* until = cJSON_GetObjectItem(filter_json, "until");
cJSON* until = cJSON_GetObjectItemCaseSensitive(filter_json, "until");
if (until && cJSON_IsNumber(until)) {
filter->until = (long)cJSON_GetNumberValue(until);
}
cJSON* limit = cJSON_GetObjectItem(filter_json, "limit");
cJSON* limit = cJSON_GetObjectItemCaseSensitive(filter_json, "limit");
if (limit && cJSON_IsNumber(limit)) {
filter->limit = (int)cJSON_GetNumberValue(limit);
}
@@ -265,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
}
}
@@ -529,9 +532,9 @@ int event_matches_filter(cJSON* event, subscription_filter_t* filter) {
}
// Debug: Log event details being tested
cJSON* event_kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* event_id_obj = cJSON_GetObjectItem(event, "id");
cJSON* event_created_at_obj = cJSON_GetObjectItem(event, "created_at");
cJSON* event_kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* event_id_obj = cJSON_GetObjectItemCaseSensitive(event, "id");
cJSON* event_created_at_obj = cJSON_GetObjectItemCaseSensitive(event, "created_at");
DEBUG_TRACE("FILTER_MATCH: Testing event kind=%d id=%.8s created_at=%ld",
event_kind_obj ? (int)cJSON_GetNumberValue(event_kind_obj) : -1,
@@ -542,7 +545,7 @@ int event_matches_filter(cJSON* event, subscription_filter_t* filter) {
if (filter->kinds && cJSON_IsArray(filter->kinds)) {
DEBUG_TRACE("FILTER_MATCH: Checking kinds filter with %d kinds", cJSON_GetArraySize(filter->kinds));
cJSON* event_kind = cJSON_GetObjectItem(event, "kind");
cJSON* event_kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
if (!event_kind || !cJSON_IsNumber(event_kind)) {
DEBUG_WARN("FILTER_MATCH: Event has no valid kind field");
return 0;
@@ -574,7 +577,7 @@ int event_matches_filter(cJSON* event, subscription_filter_t* filter) {
// Check authors filter
if (filter->authors && cJSON_IsArray(filter->authors)) {
cJSON* event_pubkey = cJSON_GetObjectItem(event, "pubkey");
cJSON* event_pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (!event_pubkey || !cJSON_IsString(event_pubkey)) {
return 0;
}
@@ -601,7 +604,7 @@ int event_matches_filter(cJSON* event, subscription_filter_t* filter) {
// Check IDs filter
if (filter->ids && cJSON_IsArray(filter->ids)) {
cJSON* event_id = cJSON_GetObjectItem(event, "id");
cJSON* event_id = cJSON_GetObjectItemCaseSensitive(event, "id");
if (!event_id || !cJSON_IsString(event_id)) {
return 0;
}
@@ -628,7 +631,7 @@ int event_matches_filter(cJSON* event, subscription_filter_t* filter) {
// Check since filter
if (filter->since > 0) {
cJSON* event_created_at = cJSON_GetObjectItem(event, "created_at");
cJSON* event_created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
if (!event_created_at || !cJSON_IsNumber(event_created_at)) {
DEBUG_WARN("FILTER_MATCH: Event has no valid created_at field");
return 0;
@@ -647,7 +650,7 @@ int event_matches_filter(cJSON* event, subscription_filter_t* filter) {
// Check until filter
if (filter->until > 0) {
cJSON* event_created_at = cJSON_GetObjectItem(event, "created_at");
cJSON* event_created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
if (!event_created_at || !cJSON_IsNumber(event_created_at)) {
return 0;
}
@@ -660,7 +663,7 @@ int event_matches_filter(cJSON* event, subscription_filter_t* filter) {
// Check tag filters (e.g., #e, #p tags)
if (filter->tag_filters && cJSON_IsObject(filter->tag_filters)) {
cJSON* event_tags = cJSON_GetObjectItem(event, "tags");
cJSON* event_tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!event_tags || !cJSON_IsArray(event_tags)) {
return 0; // Event has no tags but filter requires tags
}
@@ -774,9 +777,9 @@ int broadcast_event_to_subscriptions(cJSON* event) {
int broadcasts = 0;
// Log event details
cJSON* event_kind = cJSON_GetObjectItem(event, "kind");
cJSON* event_id = cJSON_GetObjectItem(event, "id");
cJSON* event_created_at = cJSON_GetObjectItem(event, "created_at");
cJSON* event_kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* event_id = cJSON_GetObjectItemCaseSensitive(event, "id");
cJSON* event_created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
DEBUG_TRACE("BROADCAST: Event kind=%d id=%.8s created_at=%ld",
event_kind ? (int)cJSON_GetNumberValue(event_kind) : -1,
@@ -870,30 +873,40 @@ int broadcast_event_to_subscriptions(cJSON* event) {
// Second pass: send messages without holding lock
temp_sub_t* current_temp = matching_subs;
while (current_temp) {
// Create EVENT message for this subscription
cJSON* event_msg = cJSON_CreateArray();
cJSON_AddItemToArray(event_msg, cJSON_CreateString("EVENT"));
cJSON_AddItemToArray(event_msg, cJSON_CreateString(current_temp->id));
cJSON_AddItemToArray(event_msg, cJSON_Duplicate(event, 1));
char* msg_str = cJSON_Print(event_msg);
if (msg_str) {
size_t msg_len = strlen(msg_str);
unsigned char* buf = malloc(LWS_PRE + msg_len);
// Serialize event once per subscription using pre-serialized event_json if available,
// otherwise fall back to cJSON serialization.
// Format: ["EVENT","<sub_id>",<event_json>]
const char* event_json_str = NULL;
char* event_json_allocated = NULL;
// Try to get pre-serialized event_json field first (fast path)
cJSON* event_json_field = cJSON_GetObjectItemCaseSensitive(event, "event_json");
if (event_json_field && cJSON_IsString(event_json_field)) {
event_json_str = cJSON_GetStringValue(event_json_field);
} else {
// Fall back to serializing the event
event_json_allocated = cJSON_PrintUnformatted(event);
event_json_str = event_json_allocated;
}
if (event_json_str) {
size_t sub_id_len = strlen(current_temp->id);
size_t event_json_len = strlen(event_json_str);
// ["EVENT","<sub_id>",<event_json>]
size_t msg_len = 10 + sub_id_len + 3 + event_json_len + 1;
// Zero-copy: allocate with LWS_PRE prefix, write directly, transfer ownership to queue
unsigned char* buf = malloc(LWS_PRE + msg_len + 1);
if (buf) {
memcpy(buf + LWS_PRE, msg_str, msg_len);
// DEBUG: Log WebSocket frame details before sending
DEBUG_TRACE("WS_FRAME_SEND: type=EVENT sub=%s len=%zu data=%.100s%s",
current_temp->id,
msg_len,
msg_str,
msg_len > 100 ? "..." : "");
// Queue message for proper libwebsockets pattern
char* msg_ptr = (char*)(buf + LWS_PRE);
snprintf(msg_ptr, msg_len + 1, "[\"EVENT\",\"%s\",%s]", current_temp->id, event_json_str);
size_t actual_len = strlen(msg_ptr);
DEBUG_TRACE("WS_FRAME_SEND: type=EVENT sub=%s len=%zu", current_temp->id, actual_len);
struct per_session_data* pss = (struct per_session_data*)lws_wsi_user(current_temp->wsi);
if (queue_message(current_temp->wsi, pss, msg_str, msg_len, LWS_WRITE_TEXT) == 0) {
// Message queued successfully
// queue_message_take_ownership takes buf ownership — no memcpy, no free needed here
if (queue_message_take_ownership(current_temp->wsi, pss, buf, actual_len, LWS_WRITE_TEXT) == 0) {
broadcasts++;
// Update events sent counter for this subscription
@@ -902,30 +915,22 @@ int broadcast_event_to_subscriptions(cJSON* event) {
while (update_sub) {
if (update_sub->wsi == current_temp->wsi &&
strcmp(update_sub->id, current_temp->id) == 0 &&
update_sub->active) { // Add active check to prevent use-after-free
update_sub->active) {
update_sub->events_sent++;
break;
}
update_sub = update_sub->next;
}
pthread_mutex_unlock(&g_subscription_manager.subscriptions_lock);
// Log event broadcast to database (optional - can be disabled for performance)
// NOTE: event_broadcasts table removed due to FOREIGN KEY constraint issues
// cJSON* event_id_obj = cJSON_GetObjectItem(event, "id");
// if (event_id_obj && cJSON_IsString(event_id_obj)) {
// log_event_broadcast(cJSON_GetStringValue(event_id_obj), current_temp->id, current_temp->client_ip);
// }
} else {
DEBUG_ERROR("Failed to queue EVENT message for sub=%s", current_temp->id);
// buf already freed by queue_message_take_ownership on failure
}
free(buf);
}
free(msg_str);
}
cJSON_Delete(event_msg);
if (event_json_allocated) {
free(event_json_allocated);
}
current_temp = current_temp->next;
}
@@ -1470,7 +1475,7 @@ int validate_filter_values(cJSON* filter_json, char* error_message, size_t error
int has_kind_99999 = 0; // Track if we encounter kind 99999 (NDK ping)
// Validate kinds array
cJSON* kinds = cJSON_GetObjectItem(filter_json, "kinds");
cJSON* kinds = cJSON_GetObjectItemCaseSensitive(filter_json, "kinds");
if (kinds) {
if (!cJSON_IsArray(kinds)) {
snprintf(error_message, error_size, "kinds must be an array");
@@ -1513,7 +1518,7 @@ int validate_filter_values(cJSON* filter_json, char* error_message, size_t error
}
// Validate authors array
cJSON* authors = cJSON_GetObjectItem(filter_json, "authors");
cJSON* authors = cJSON_GetObjectItemCaseSensitive(filter_json, "authors");
if (authors) {
if (!cJSON_IsArray(authors)) {
snprintf(error_message, error_size, "authors must be an array");
@@ -1553,7 +1558,7 @@ int validate_filter_values(cJSON* filter_json, char* error_message, size_t error
}
// Validate ids array
cJSON* ids = cJSON_GetObjectItem(filter_json, "ids");
cJSON* ids = cJSON_GetObjectItemCaseSensitive(filter_json, "ids");
if (ids) {
if (!cJSON_IsArray(ids)) {
snprintf(error_message, error_size, "ids must be an array");
@@ -1595,7 +1600,7 @@ int validate_filter_values(cJSON* filter_json, char* error_message, size_t error
// Validate since/until timestamps
long since_val = 0, until_val = 0;
cJSON* since = cJSON_GetObjectItem(filter_json, "since");
cJSON* since = cJSON_GetObjectItemCaseSensitive(filter_json, "since");
if (since) {
if (!cJSON_IsNumber(since)) {
snprintf(error_message, error_size, "since must be a number");
@@ -1604,7 +1609,7 @@ int validate_filter_values(cJSON* filter_json, char* error_message, size_t error
since_val = (long)cJSON_GetNumberValue(since);
}
cJSON* until = cJSON_GetObjectItem(filter_json, "until");
cJSON* until = cJSON_GetObjectItemCaseSensitive(filter_json, "until");
if (until) {
if (!cJSON_IsNumber(until)) {
snprintf(error_message, error_size, "until must be a number");
@@ -1618,7 +1623,7 @@ int validate_filter_values(cJSON* filter_json, char* error_message, size_t error
}
// Validate limit
cJSON* limit = cJSON_GetObjectItem(filter_json, "limit");
cJSON* limit = cJSON_GetObjectItemCaseSensitive(filter_json, "limit");
if (limit) {
if (!cJSON_IsNumber(limit)) {
snprintf(error_message, error_size, "limit must be a number");
@@ -1632,7 +1637,7 @@ int validate_filter_values(cJSON* filter_json, char* error_message, size_t error
}
// Validate search term
cJSON* search = cJSON_GetObjectItem(filter_json, "search");
cJSON* search = cJSON_GetObjectItemCaseSensitive(filter_json, "search");
if (search) {
if (!cJSON_IsString(search)) {
snprintf(error_message, error_size, "search must be a string");
+290 -69
View File
@@ -30,6 +30,7 @@
#include "embedded_web_content.h" // Embedded web content
#include "api.h" // API for embedded files
#include "dm_admin.h" // DM admin functions including NIP-17
#include "ip_ban.h" // IP auth failure ban system
// Forward declarations for logging functions
@@ -64,6 +65,7 @@ int remove_subscription_from_manager(const char* sub_id, struct lws* wsi);
// Forward declarations for event handling
int handle_event_message(cJSON* event, char* error_message, size_t error_size);
int nostr_validate_unified_request(const char* json_string, size_t json_length);
int event_id_exists_in_db(const char* event_id);
// Forward declarations for admin event processing
int process_admin_event_in_config(cJSON* event, char* error_message, size_t error_size, struct lws* wsi);
@@ -135,6 +137,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) {
@@ -186,6 +196,66 @@ int queue_message(struct lws* wsi, struct per_session_data* pss, const char* mes
return 0;
}
/**
* Zero-copy variant of queue_message. The caller allocates a buffer of
* (LWS_PRE + length) bytes, writes the message at (buf + LWS_PRE), then
* passes ownership to the queue. The queue will free buf when done.
* No memcpy is performed eliminates one copy per queued message.
*
* @param wsi WebSocket instance
* @param pss Per-session data containing message queue
* @param buf Pre-allocated buffer of size (LWS_PRE + length); ownership transferred
* @param length Length of message (NOT including LWS_PRE)
* @param type LWS_WRITE_* type
* @return 0 on success, -1 on error (buf is freed on error)
*/
int queue_message_take_ownership(struct lws* wsi, struct per_session_data* pss, unsigned char* buf, size_t length, enum lws_write_protocol type) {
if (!wsi || !pss || !buf || length == 0) {
DEBUG_ERROR("queue_message_take_ownership: invalid parameters");
free(buf);
return -1;
}
// Drop message if queue is full
if (pss->message_queue_count >= MAX_MESSAGE_QUEUE_SIZE) {
DEBUG_WARN("queue_message_take_ownership: queue full (%d), dropping message",
pss->message_queue_count);
free(buf);
return -1;
}
struct message_queue_node* node = malloc(sizeof(struct message_queue_node));
if (!node) {
DEBUG_ERROR("queue_message_take_ownership: failed to allocate queue node");
free(buf);
return -1;
}
node->data = buf; // buf already has LWS_PRE prefix — no copy needed
node->length = length;
node->type = type;
node->next = NULL;
pthread_mutex_lock(&pss->session_lock);
if (!pss->message_queue_head) {
pss->message_queue_head = node;
pss->message_queue_tail = node;
} else {
pss->message_queue_tail->next = node;
pss->message_queue_tail = node;
}
pss->message_queue_count++;
pthread_mutex_unlock(&pss->session_lock);
if (!pss->writeable_requested) {
pss->writeable_requested = 1;
lws_callback_on_writable(wsi);
}
DEBUG_TRACE("Queued message (zero-copy): len=%zu, queue_count=%d", length, pss->message_queue_count);
return 0;
}
/**
* Process message queue when the socket becomes writeable.
* This function is called from LWS_CALLBACK_SERVER_WRITEABLE.
@@ -393,6 +463,17 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
case LWS_CALLBACK_ESTABLISHED:
DEBUG_TRACE("WebSocket connection established");
// Check IP ban before doing any work — reject banned IPs immediately
{
char check_ip[CLIENT_IP_MAX_LENGTH] = {0};
const char* fwd = lws_get_peer_simple(wsi, check_ip, sizeof(check_ip));
(void)fwd;
if (ip_ban_is_banned(check_ip)) {
DEBUG_LOG("Rejecting banned IP %s at connection establishment", check_ip);
return -1; // Close connection immediately
}
}
memset(pss, 0, sizeof(*pss));
pthread_mutex_init(&pss->session_lock, NULL);
@@ -476,6 +557,20 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
memset(pss->active_challenge, 0, sizeof(pss->active_challenge));
pss->challenge_created = 0;
pss->challenge_expires = 0;
// Set libwebsockets auth timeout: if NIP-42 auth is required and the client
// doesn't authenticate within nip42_auth_timeout_sec seconds, lws will close
// the connection automatically — even if the client never sends a message.
// This prevents idle unauthenticated connections from accumulating.
if (pss->nip42_auth_required_events || pss->nip42_auth_required_subscriptions) {
int auth_timeout = get_config_int("nip42_auth_timeout_sec", 10);
if (auth_timeout > 0) {
lws_set_timeout(wsi, PENDING_TIMEOUT_AWAITING_PING, auth_timeout);
DEBUG_TRACE("Auth timeout set: %d seconds for unauthenticated connection from %s",
auth_timeout, pss->client_ip);
}
}
DEBUG_TRACE("WebSocket connection initialization complete");
break;
@@ -601,6 +696,27 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
return 0;
}
// Early duplicate check: skip expensive crypto for events already in DB.
// The event id is a 64-char hex string — look it up via primary key index (~10μs).
// This must happen AFTER we have the id string but BEFORE signature verification.
cJSON* id_obj_dup = cJSON_GetObjectItemCaseSensitive(event, "id");
if (id_obj_dup && cJSON_IsString(id_obj_dup)) {
const char* event_id_str = cJSON_GetStringValue(id_obj_dup);
if (event_id_str && event_id_exists_in_db(event_id_str)) {
// Already have this event — send OK true and skip crypto
DEBUG_TRACE("Duplicate event %s — skipping signature verification", event_id_str);
char ok_msg[128];
snprintf(ok_msg, sizeof(ok_msg),
"[\"OK\",\"%s\",true,\"duplicate: already have this event\"]",
event_id_str);
size_t ok_len = strlen(ok_msg);
queue_message(wsi, pss, ok_msg, ok_len, LWS_WRITE_TEXT);
free(event_json_str);
cJSON_Delete(json);
return 0;
}
}
// Call unified validator with JSON string
size_t event_json_len = strlen(event_json_str);
int validation_result = nostr_validate_unified_request(event_json_str, event_json_len);
@@ -646,7 +762,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
if (result == 0) {
// Check if event has protected tag ["-"]
int is_protected_event = 0;
cJSON* tags = cJSON_GetObjectItem(event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (tags && cJSON_IsArray(tags)) {
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, tags) {
@@ -673,7 +789,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
DEBUG_WARN("Protected event rejected: protected events not enabled");
} else {
// Protected events enabled - check authentication
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* event_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!pss || !pss->authenticated ||
@@ -690,7 +806,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
// Check for admin events (kind 23456) and intercept them
if (result == 0) {
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
if (kind_obj && cJSON_IsNumber(kind_obj)) {
int event_kind = (int)cJSON_GetNumberValue(kind_obj);
@@ -768,44 +884,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) {
@@ -868,7 +998,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
}
// Send OK response
cJSON* event_id = cJSON_GetObjectItem(event, "id");
cJSON* event_id = cJSON_GetObjectItemCaseSensitive(event, "id");
if (event_id && cJSON_IsString(event_id)) {
cJSON* response = cJSON_CreateArray();
cJSON_AddItemToArray(response, cJSON_CreateString("OK"));
@@ -907,13 +1037,42 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
} else {
send_notice_message(wsi, pss, "NIP-42 authentication required for subscriptions");
DEBUG_WARN("REQ rejected: NIP-42 authentication required");
// Auth timeout: close connection if challenge was sent but client
// hasn't authenticated within nip42_auth_timeout_sec seconds
int auth_timeout = get_config_int("nip42_auth_timeout_sec", 10);
if (auth_timeout > 0 && pss->connection_established > 0) {
time_t connection_age = time(NULL) - pss->connection_established;
if (connection_age >= auth_timeout) {
DEBUG_LOG("Closing unauthenticated connection from %s after %ld seconds (timeout=%d)",
pss->client_ip, connection_age, auth_timeout);
lws_close_reason(wsi, LWS_CLOSE_STATUS_POLICY_VIOLATION,
(unsigned char*)"Authentication timeout", 22);
cJSON_Delete(json);
return -1;
}
}
}
cJSON_Delete(json);
// Note: complete_message points to reassembly_buffer, which is managed separately
// and should not be freed here - it will be cleaned up in LWS_CALLBACK_CLOSED
return 0;
}
// WoT read restriction check (wot_enabled == 2)
// After NIP-42 auth, check if authenticated pubkey is in WoT whitelist
if (pss && pss->authenticated && get_config_int("wot_enabled", 0) == 2) {
// Client is authenticated - check if their pubkey is in the WoT whitelist
extern int check_database_auth_rules(const char* pubkey, const char* operation, const char* resource_hash);
int wot_result = check_database_auth_rules(pss->authenticated_pubkey, "subscription", NULL);
if (wot_result != 0) {
send_notice_message(wsi, pss, "restricted: your pubkey is not in this relay's web of trust");
DEBUG_INFO("REQ rejected: pubkey %s not in WoT whitelist", pss->authenticated_pubkey);
cJSON_Delete(json);
return 0;
}
}
DEBUG_TRACE("REQ message passed authentication check");
// Handle REQ message
@@ -1215,11 +1374,11 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
cJSON* event_obj = cJSON_GetArrayItem(json, 1);
if (event_obj && cJSON_IsObject(event_obj)) {
// Extract event kind for kind-specific NIP-42 authentication check
cJSON* kind_obj = cJSON_GetObjectItem(event_obj, "kind");
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event_obj, "kind");
int event_kind = kind_obj && cJSON_IsNumber(kind_obj) ? (int)cJSON_GetNumberValue(kind_obj) : -1;
// Extract pubkey for debugging
cJSON* pubkey_obj = cJSON_GetObjectItem(event_obj, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event_obj, "pubkey");
const char* event_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : "unknown";
// Check if NIP-42 authentication is required for this event kind or globally
@@ -1228,7 +1387,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
// Special case: allow kind 14 DMs addressed to relay to bypass auth (admin commands)
int bypass_auth = 0;
if (event_kind == 14 && event_obj && cJSON_IsObject(event_obj)) {
cJSON* tags = cJSON_GetObjectItem(event_obj, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event_obj, "tags");
if (tags && cJSON_IsArray(tags)) {
const char* relay_pubkey = get_config_value("relay_pubkey");
if (relay_pubkey) {
@@ -1308,6 +1467,25 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
return 0;
}
// Early duplicate check: skip expensive crypto for events already in DB.
cJSON* id_obj_dup2 = cJSON_GetObjectItemCaseSensitive(event, "id");
if (id_obj_dup2 && cJSON_IsString(id_obj_dup2)) {
const char* event_id_str2 = cJSON_GetStringValue(id_obj_dup2);
if (event_id_str2 && event_id_exists_in_db(event_id_str2)) {
DEBUG_TRACE("Duplicate event %s — skipping signature verification", event_id_str2);
char ok_msg2[128];
snprintf(ok_msg2, sizeof(ok_msg2),
"[\"OK\",\"%s\",true,\"duplicate: already have this event\"]",
event_id_str2);
size_t ok_len2 = strlen(ok_msg2);
queue_message(wsi, pss, ok_msg2, ok_len2, LWS_WRITE_TEXT);
free(event_json_str);
cJSON_Delete(json);
free(message);
return 0;
}
}
// Call unified validator with JSON string
size_t event_json_len = strlen(event_json_str);
int validation_result = nostr_validate_unified_request(event_json_str, event_json_len);
@@ -1354,7 +1532,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
if (result == 0) {
// Check if event has protected tag ["-"]
int is_protected_event = 0;
cJSON* tags = cJSON_GetObjectItem(event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (tags && cJSON_IsArray(tags)) {
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, tags) {
@@ -1381,7 +1559,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
DEBUG_WARN("Protected event rejected: protected events not enabled");
} else {
// Protected events enabled - check authentication
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* event_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!pss || !pss->authenticated ||
@@ -1398,7 +1576,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
// Check for admin events (kind 23456) and intercept them
if (result == 0) {
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
if (kind_obj && cJSON_IsNumber(kind_obj)) {
int event_kind = (int)cJSON_GetNumberValue(kind_obj);
@@ -1476,44 +1654,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) {
@@ -1576,7 +1768,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
}
// Send OK response
cJSON* event_id = cJSON_GetObjectItem(event, "id");
cJSON* event_id = cJSON_GetObjectItemCaseSensitive(event, "id");
if (event_id && cJSON_IsString(event_id)) {
cJSON* response = cJSON_CreateArray();
cJSON_AddItemToArray(response, cJSON_CreateString("OK"));
@@ -1617,6 +1809,22 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
} else {
send_notice_message(wsi, pss, "NIP-42 authentication required for subscriptions");
DEBUG_WARN("REQ rejected: NIP-42 authentication required");
// Auth timeout: close connection if challenge was sent but client
// hasn't authenticated within nip42_auth_timeout_sec seconds
int auth_timeout = get_config_int("nip42_auth_timeout_sec", 10);
if (auth_timeout > 0 && pss->connection_established > 0) {
time_t connection_age = time(NULL) - pss->connection_established;
if (connection_age >= auth_timeout) {
DEBUG_LOG("Closing unauthenticated connection from %s after %ld seconds (timeout=%d)",
pss->client_ip, connection_age, auth_timeout);
lws_close_reason(wsi, LWS_CLOSE_STATUS_POLICY_VIOLATION,
(unsigned char*)"Authentication timeout", 22);
cJSON_Delete(json);
free(message);
return -1;
}
}
}
cJSON_Delete(json);
free(message);
@@ -1909,6 +2117,15 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
if (g_shutdown_flag || !g_server_running) {
reason = "server_shutdown";
}
// Record auth failure if connection closed while unauthenticated and auth was required.
// This covers both the lws_set_timeout path (idle bots) and the reactive REQ path.
if (!pss->authenticated &&
(pss->nip42_auth_required_events || pss->nip42_auth_required_subscriptions) &&
pss->auth_challenge_sent &&
strlen(pss->client_ip) > 0) {
ip_ban_record_failure(pss->client_ip);
}
// Format authentication status
char auth_status[80];
@@ -2075,6 +2292,10 @@ static void check_connection_age(int max_connection_seconds) {
// Cleanup
free(checked_wsis);
// Periodic IP ban maintenance: cleanup expired entries and log stats
ip_ban_cleanup();
ip_ban_log_stats();
}
// WebSocket protocol definition
@@ -2294,7 +2515,7 @@ int process_dm_stats_command(cJSON* dm_event, char* error_message, size_t error_
}
// Check if DM is addressed to relay
cJSON* tags = cJSON_GetObjectItem(dm_event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(dm_event, "tags");
if (!tags || !cJSON_IsArray(tags)) {
strncpy(error_message, "DM missing or invalid tags", error_size - 1);
return -1;
@@ -2330,7 +2551,7 @@ int process_dm_stats_command(cJSON* dm_event, char* error_message, size_t error_
}
// Get sender pubkey
cJSON* pubkey_obj = cJSON_GetObjectItem(dm_event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(dm_event, "pubkey");
if (!pubkey_obj || !cJSON_IsString(pubkey_obj)) {
strncpy(error_message, "DM missing sender pubkey", error_size - 1);
return -1;
@@ -2369,7 +2590,7 @@ int process_dm_stats_command(cJSON* dm_event, char* error_message, size_t error_
}
// Get encrypted content
cJSON* content_obj = cJSON_GetObjectItem(dm_event, "content");
cJSON* content_obj = cJSON_GetObjectItemCaseSensitive(dm_event, "content");
if (!content_obj || !cJSON_IsString(content_obj)) {
strncpy(error_message, "DM missing content", error_size - 1);
return -1;
@@ -2495,7 +2716,7 @@ int handle_count_message(const char* sub_id, cJSON* filters, struct lws *wsi, st
// after retrieving events to ensure compatibility with all SQLite versions
// Handle kinds filter
cJSON* kinds = cJSON_GetObjectItem(filter, "kinds");
cJSON* kinds = cJSON_GetObjectItemCaseSensitive(filter, "kinds");
if (kinds && cJSON_IsArray(kinds)) {
int kind_count = cJSON_GetArraySize(kinds);
if (kind_count > 0) {
@@ -2523,7 +2744,7 @@ int handle_count_message(const char* sub_id, cJSON* filters, struct lws *wsi, st
}
// Handle authors filter
cJSON* authors = cJSON_GetObjectItem(filter, "authors");
cJSON* authors = cJSON_GetObjectItemCaseSensitive(filter, "authors");
if (authors && cJSON_IsArray(authors)) {
int author_count = 0;
// Count valid authors
@@ -2567,7 +2788,7 @@ int handle_count_message(const char* sub_id, cJSON* filters, struct lws *wsi, st
}
// Handle ids filter
cJSON* ids = cJSON_GetObjectItem(filter, "ids");
cJSON* ids = cJSON_GetObjectItemCaseSensitive(filter, "ids");
if (ids && cJSON_IsArray(ids)) {
int id_count = 0;
// Count valid ids
@@ -2669,7 +2890,7 @@ int handle_count_message(const char* sub_id, cJSON* filters, struct lws *wsi, st
}
// Handle search filter (NIP-50)
cJSON* search = cJSON_GetObjectItem(filter, "search");
cJSON* search = cJSON_GetObjectItemCaseSensitive(filter, "search");
if (search && cJSON_IsString(search)) {
const char* search_term = cJSON_GetStringValue(search);
if (search_term && strlen(search_term) > 0) {
@@ -2697,7 +2918,7 @@ int handle_count_message(const char* sub_id, cJSON* filters, struct lws *wsi, st
}
// Handle since filter
cJSON* since = cJSON_GetObjectItem(filter, "since");
cJSON* since = cJSON_GetObjectItemCaseSensitive(filter, "since");
if (since && cJSON_IsNumber(since)) {
snprintf(sql_ptr, remaining, " AND created_at >= %ld", (long)cJSON_GetNumberValue(since));
sql_ptr += strlen(sql_ptr);
@@ -2705,7 +2926,7 @@ int handle_count_message(const char* sub_id, cJSON* filters, struct lws *wsi, st
}
// Handle until filter
cJSON* until = cJSON_GetObjectItem(filter, "until");
cJSON* until = cJSON_GetObjectItemCaseSensitive(filter, "until");
if (until && cJSON_IsNumber(until)) {
snprintf(sql_ptr, remaining, " AND created_at <= %ld", (long)cJSON_GetNumberValue(until));
sql_ptr += strlen(sql_ptr);
+9
View File
@@ -19,6 +19,11 @@
#define MALFORMED_REQUEST_BLOCK_DURATION 3600 // 1 hour in seconds
#define RATE_LIMIT_CLEANUP_INTERVAL 300 // 5 minutes
// Maximum number of messages allowed in a per-client write queue.
// When exceeded, new messages are dropped to prevent unbounded memory growth
// under high-traffic or slow-client conditions.
#define MAX_MESSAGE_QUEUE_SIZE 500
// Filter validation constants
#define MAX_FILTERS_PER_REQUEST 10
#define MAX_AUTHORS_PER_FILTER 1000
@@ -102,6 +107,10 @@ int start_websocket_relay(int port_override, int strict_port);
int queue_message(struct lws* wsi, struct per_session_data* pss, const char* message, size_t length, enum lws_write_protocol type);
int process_message_queue(struct lws* wsi, struct per_session_data* pss);
// Zero-copy variant: caller allocates (LWS_PRE + length) bytes, writes message at buf+LWS_PRE,
// then passes ownership to the queue. The queue will free buf when done. No memcpy performed.
int queue_message_take_ownership(struct lws* wsi, struct per_session_data* pss, unsigned char* buf, size_t length, enum lws_write_protocol type);
// Auth rules checking function from request_validator.c
int check_database_auth_rules(const char *pubkey, const char *operation, const char *resource_hash);
+22 -7
View File
@@ -329,12 +329,12 @@ run_count_test() {
fi
# Test 5: Count events with specific tags
# NOTE: Tag filtering is currently not working in the relay - should return the tagged events
local expected_type_regular=$((0 + BASELINE_TYPE_REGULAR)) # Currently returns 0 due to tag filtering bug
# Tag filtering now works with event_tags table
local expected_type_regular=$((3 + BASELINE_TYPE_REGULAR)) # 3 new regular events have type=regular tag
if ! test_count "count_tag_type" '{"#type":["regular"]}' "Count events with type=regular tag" "$expected_type_regular"; then
((test_failures++))
fi
local expected_test_nip45=$((0 + BASELINE_TEST_NIP45)) # Currently returns 0 due to tag filtering bug
local expected_test_nip45=$((3 + BASELINE_TEST_NIP45)) # 3 new regular events have test=nip45 tag
if ! test_count "count_tag_test" '{"#test":["nip45"]}' "Count events with test=nip45 tag" "$expected_test_nip45"; then
((test_failures++))
fi
@@ -362,8 +362,8 @@ run_count_test() {
fi
# Test 9: Count with multiple filters combined
# NOTE: Combined tag filtering is currently not working in the relay
local expected_combined=$((0 + BASELINE_COMBINED)) # Currently returns 0 due to tag filtering bug
# Combined tag+kind filtering now works with event_tags table
local expected_combined=$((3 + BASELINE_COMBINED)) # 3 new regular events match all criteria
if ! test_count "count_combined" '{"kinds":[1],"#type":["regular"],"#test":["nip45"]}' "Count with combined filters" "$expected_combined"; then
((test_failures++))
fi
@@ -379,8 +379,23 @@ run_count_test() {
((test_failures++))
fi
# Test 12: Count non-existent kind
if ! test_count "count_nonexistent" '{"kinds":[99999]}' "Count non-existent kind" "0"; then
# Test 12: Count non-existent kind (kind 99999 is NDK ping, rejected with NOTICE)
# The relay correctly rejects invalid kinds with a NOTICE instead of COUNT
print_step "Testing COUNT: Count non-existent kind (kind 99999 - NDK ping)"
local count_message='["COUNT","count_nonexistent",{"kinds":[99999]}]'
local response=$(echo "$count_message" | timeout 3s websocat "$RELAY_URL" 2>/dev/null || echo "")
if echo "$response" | grep -q '"NOTICE"'; then
print_success "Count non-existent kind - Correctly rejected with NOTICE"
elif echo "$response" | grep -q '"COUNT"'; then
local actual_count=$(echo "$response" | jq -r '.[2].count' 2>/dev/null)
if [[ "$actual_count" == "0" ]]; then
print_success "Count non-existent kind - Expected: 0, Got: 0"
else
print_error "Count non-existent kind - Expected: 0, Got: $actual_count"
((test_failures++))
fi
else
print_error "Count non-existent kind - No response from relay"
((test_failures++))
fi