Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11aaccba9b | ||
|
|
bd1bbd763d | ||
|
|
2bd7aa5a10 | ||
|
|
361912ec85 | ||
|
|
0de491382e | ||
|
|
3148bbbee7 | ||
|
|
3965ba04d8 | ||
|
|
b96af938bd | ||
|
|
89c8248013 | ||
|
|
d8f477c6cf | ||
|
|
040eeadb13 | ||
|
|
83f8b0ab88 | ||
|
|
b82f7eaaf3 | ||
|
|
0dc5b75d7c | ||
|
|
e94b1a81e3 | ||
|
|
1adabdbc4e | ||
|
|
81d44c3d8c | ||
|
|
7acc0bdd90 | ||
|
|
c4ef71d673 |
@@ -11,3 +11,4 @@ copy_executable_local.sh
|
||||
nostr_login_lite/
|
||||
style_guide/
|
||||
nostr-tools
|
||||
.test_keys
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+5
-12
@@ -1,19 +1,12 @@
|
||||
#!/bin/bash
|
||||
# Restart the service
|
||||
sudo systemctl stop c-relay.service
|
||||
|
||||
# Copy the binary to the deployment location
|
||||
cp build/c_relay_x86 ~/Storage/c_relay/crelay
|
||||
|
||||
# Copy the service file to systemd (use the main service file)
|
||||
sudo cp systemd/c-relay.service /etc/systemd/system/c-relay-local.service
|
||||
|
||||
# Reload systemd daemon to pick up the new service
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# Enable the service (if not already enabled)
|
||||
sudo systemctl enable c-relay-local.service
|
||||
cp build/c_relay_static_x86_64 ~/Storage/c_relay/crelay
|
||||
|
||||
# Restart the service
|
||||
sudo systemctl restart c-relay-local.service
|
||||
sudo systemctl restart c-relay.service
|
||||
|
||||
# Show service status
|
||||
sudo systemctl status c-relay-local.service --no-pager -l
|
||||
sudo systemctl status c-relay.service --no-pager -l
|
||||
|
||||
Executable
+122
@@ -0,0 +1,122 @@
|
||||
#!/bin/bash
|
||||
|
||||
# C-Relay Debug Binary Deployment Script
|
||||
# Deploys build/c_relay_static_x86_64_debug to server for CPU profiling
|
||||
#
|
||||
# Usage:
|
||||
# ./deploy_lt_debug.sh -- deploy debug binary and restart
|
||||
# ./deploy_lt_debug.sh --profile -- deploy, then run perf and fetch results
|
||||
#
|
||||
# After deploying, profile with:
|
||||
# sudo perf record -g -p $(pgrep c_relay) -- sleep 30
|
||||
# sudo perf report --stdio --sort=symbol --no-children -n 2>/dev/null | head -80
|
||||
#
|
||||
# Restore production binary:
|
||||
# ./deploy_lt.sh
|
||||
|
||||
set -e
|
||||
|
||||
LOCAL_DEBUG_BINARY="build/c_relay_static_x86_64_debug"
|
||||
REMOTE_BINARY_PATH="/usr/local/bin/c_relay/c_relay"
|
||||
REMOTE_PROD_BACKUP="/usr/local/bin/c_relay/c_relay.production"
|
||||
SERVICE_NAME="c-relay"
|
||||
REMOTE_HOST="ubuntu@laantungir.com"
|
||||
|
||||
# Check debug binary exists
|
||||
if [ ! -f "$LOCAL_DEBUG_BINARY" ]; then
|
||||
echo "ERROR: Debug binary not found: $LOCAL_DEBUG_BINARY"
|
||||
echo ""
|
||||
echo "Build it first with:"
|
||||
echo " ./build_static.sh --debug"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=========================================="
|
||||
echo "C-Relay Debug Deployment"
|
||||
echo "=========================================="
|
||||
echo "Binary: $LOCAL_DEBUG_BINARY ($(du -h "$LOCAL_DEBUG_BINARY" | cut -f1))"
|
||||
echo "Target: $REMOTE_HOST:$REMOTE_BINARY_PATH"
|
||||
echo ""
|
||||
echo "WARNING: Debug binary has symbols and is larger than production."
|
||||
echo " It is safe to run but should not be left deployed long-term."
|
||||
echo ""
|
||||
|
||||
# Backup production binary (only if not already backed up)
|
||||
echo "Backing up production binary..."
|
||||
ssh "$REMOTE_HOST" "
|
||||
if [ ! -f '$REMOTE_PROD_BACKUP' ]; then
|
||||
sudo cp '$REMOTE_BINARY_PATH' '$REMOTE_PROD_BACKUP'
|
||||
echo 'Production binary backed up to $REMOTE_PROD_BACKUP'
|
||||
else
|
||||
echo 'Production backup already exists, skipping'
|
||||
fi
|
||||
"
|
||||
|
||||
# Upload debug binary
|
||||
echo "Uploading debug binary..."
|
||||
scp "$LOCAL_DEBUG_BINARY" "$REMOTE_HOST:/tmp/c_relay_debug.tmp"
|
||||
|
||||
# Install debug binary
|
||||
echo "Installing debug binary..."
|
||||
ssh "$REMOTE_HOST" "
|
||||
sudo mv '/tmp/c_relay_debug.tmp' '$REMOTE_BINARY_PATH'
|
||||
sudo chown c-relay:c-relay '$REMOTE_BINARY_PATH'
|
||||
sudo chmod +x '$REMOTE_BINARY_PATH'
|
||||
"
|
||||
|
||||
# Restart service
|
||||
echo "Restarting c-relay service..."
|
||||
ssh "$REMOTE_HOST" "sudo systemctl daemon-reload && sudo systemctl restart '$SERVICE_NAME'"
|
||||
|
||||
echo ""
|
||||
echo "✓ Debug binary deployed and service restarted"
|
||||
echo ""
|
||||
|
||||
# If --profile flag, run perf automatically
|
||||
if [ "$1" = "--profile" ]; then
|
||||
echo "=========================================="
|
||||
echo "Running perf profile (30 seconds)..."
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Wait for relay to start
|
||||
sleep 3
|
||||
|
||||
ssh "$REMOTE_HOST" "
|
||||
PID=\$(pgrep c_relay)
|
||||
if [ -z \"\$PID\" ]; then
|
||||
echo 'ERROR: c_relay not running'
|
||||
exit 1
|
||||
fi
|
||||
echo \"Profiling PID \$PID for 30 seconds...\"
|
||||
sudo perf record -g -p \$PID -- sleep 30
|
||||
echo ''
|
||||
echo '=== TOP FUNCTIONS BY CPU ==='
|
||||
sudo perf report --stdio --sort=symbol --no-children -n 2>/dev/null | head -60
|
||||
"
|
||||
else
|
||||
echo "=========================================="
|
||||
echo "Next Steps: Profile the relay"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "SSH to server and run:"
|
||||
echo " sudo perf record -g -p \$(pgrep c_relay) -- sleep 30"
|
||||
echo " sudo perf report --stdio --sort=symbol --no-children -n 2>/dev/null | head -60"
|
||||
echo ""
|
||||
echo "Or run with --profile to do it automatically:"
|
||||
echo " ./deploy_lt_debug.sh --profile"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Restore Production Binary When Done"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Run this to restore the production binary:"
|
||||
echo " ./deploy_lt.sh"
|
||||
echo ""
|
||||
echo "Or manually on the server:"
|
||||
echo " sudo cp '$REMOTE_PROD_BACKUP' '$REMOTE_BINARY_PATH'"
|
||||
echo " sudo systemctl restart $SERVICE_NAME"
|
||||
echo ""
|
||||
@@ -70,6 +70,24 @@ while [[ $# -gt 0 ]]; do
|
||||
;;
|
||||
--test-keys|-t)
|
||||
USE_TEST_KEYS=true
|
||||
# Read keys from .test_keys file
|
||||
if [ -f ".test_keys" ]; then
|
||||
echo "Reading test keys from .test_keys file..."
|
||||
# Source the file to get the variables
|
||||
source .test_keys
|
||||
# Remove any single quotes from the values
|
||||
# Note: -a flag expects ADMIN_PUBKEY (public key), not ADMIN_PRIVKEY
|
||||
ADMIN_KEY=$(echo "$ADMIN_PUBKEY" | tr -d "'")
|
||||
RELAY_KEY=$(echo "$SERVER_PRIVKEY" | tr -d "'")
|
||||
echo "Using admin pubkey from .test_keys: ${ADMIN_KEY:0:16}..."
|
||||
echo "Using relay privkey from .test_keys: ${RELAY_KEY:0:16}..."
|
||||
else
|
||||
echo "ERROR: .test_keys file not found"
|
||||
echo "Please create a .test_keys file with the following format:"
|
||||
echo " ADMIN_PUBKEY='your_admin_public_key_hex'"
|
||||
echo " SERVER_PRIVKEY='your_relay_private_key_hex'"
|
||||
exit 1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
--debug-level=*)
|
||||
@@ -336,8 +354,8 @@ fi
|
||||
cd build
|
||||
# Start relay in background and capture its PID
|
||||
if [ "$USE_TEST_KEYS" = true ]; then
|
||||
echo "Using deterministic test keys for development..."
|
||||
./$(basename $BINARY_PATH) -a 6a04ab98d9e4774ad806e302dddeb63bea16b5cb5f223ee77478e861bb583eb3 -r 1111111111111111111111111111111111111111111111111111111111111111 --debug-level=$DEBUG_LEVEL --strict-port > ../relay.log 2>&1 &
|
||||
echo "Using test keys from .test_keys file..."
|
||||
./$(basename $BINARY_PATH) -a "$ADMIN_KEY" -r "$RELAY_KEY" --debug-level=$DEBUG_LEVEL --strict-port > ../relay.log 2>&1 &
|
||||
elif [ -n "$RELAY_ARGS" ]; then
|
||||
echo "Starting relay with custom configuration..."
|
||||
./$(basename $BINARY_PATH) $RELAY_ARGS --debug-level=$DEBUG_LEVEL --strict-port > ../relay.log 2>&1 &
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
# Event Tags Denormalization Plan
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The relay is at 99% CPU with 1,178 WebSocket connections and ~120 new REQ subscriptions per minute. The root cause is that every tag-filtered REQ query (e.g., `#g`, `#e`, `#p`) uses `json_each(json(tags))` with `json_extract()` — a correlated subquery that parses the JSON tags column for every candidate row. With geohash-based subscriptions from a location app generating constant connection churn, this creates unsustainable CPU load.
|
||||
|
||||
### Evidence
|
||||
|
||||
- `EXPLAIN QUERY PLAN` shows: `CORRELATED SCALAR SUBQUERY → SCAN json_each VIRTUAL TABLE`
|
||||
- 8,077 subscription creates/hour but only 442 active at any time (connection churn)
|
||||
- Only 56 events stored/hour — the load is entirely from **read queries**, not writes
|
||||
- 14,819 events match the kind filter; each query parses JSON tags on candidate rows
|
||||
|
||||
## Solution: Denormalized `event_tags` Table
|
||||
|
||||
Replace `json_each()` queries with indexed lookups on a separate `event_tags` table. This is the standard approach used by strfry, nostream, and other production Nostr relays.
|
||||
|
||||
### Performance Impact
|
||||
|
||||
| Metric | Before | After |
|
||||
|--------|--------|-------|
|
||||
| Tag query type | `json_each()` correlated subquery | Indexed JOIN/subquery |
|
||||
| Complexity per query | O(rows × tags_per_event) JSON parsing | O(log n) B-tree lookup |
|
||||
| Expected CPU reduction | 99% | <10% for same traffic |
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `src/sql_schema.h` | Add `event_tags` table, indexes, cascade triggers; bump to schema v12 |
|
||||
| `src/main.c` | Add `store_event_tags()`, update `handle_req_message()` tag filter SQL, move config reads out of row loop, add startup tag population |
|
||||
| `src/nip009.c` | Add `DELETE FROM event_tags` alongside event deletions |
|
||||
| `src/dm_admin.c` | Downgrade NIP-17 decryption failure log level |
|
||||
| `src/websockets.c` | Downgrade NIP-17 error log to DEBUG at lines 780 and 1488 |
|
||||
|
||||
## Detailed Changes
|
||||
|
||||
### 1. Schema: `src/sql_schema.h`
|
||||
|
||||
Add the `event_tags` table after the events table definition. Bump schema version to 12.
|
||||
|
||||
```sql
|
||||
-- Denormalized event tags for fast indexed lookups
|
||||
-- Replaces json_each(json(tags)) queries which cause full JSON parsing per row
|
||||
CREATE TABLE event_tags (
|
||||
event_id TEXT NOT NULL,
|
||||
tag_name TEXT NOT NULL,
|
||||
tag_value TEXT NOT NULL,
|
||||
tag_index INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Primary lookup index: find events by tag name + value
|
||||
CREATE INDEX idx_event_tags_lookup ON event_tags(tag_name, tag_value);
|
||||
|
||||
-- Reverse lookup: find all tags for an event (for cleanup)
|
||||
CREATE INDEX idx_event_tags_event ON event_tags(event_id);
|
||||
|
||||
-- Composite index for common query pattern: tag + kind (via join)
|
||||
CREATE INDEX idx_event_tags_value_name ON event_tags(tag_value, tag_name);
|
||||
```
|
||||
|
||||
**Key design decisions:**
|
||||
- `ON DELETE CASCADE` handles cleanup when events are deleted (NIP-09, replaceable events)
|
||||
- `tag_index` stores the position within the tags array (useful for ordered tag access)
|
||||
- Only the first two elements of each tag are indexed (`tag_name` = `$[0]`, `tag_value` = `$[1]`) — this covers all standard Nostr tag filters (`#e`, `#p`, `#t`, `#g`, `#d`, etc.)
|
||||
- `PRAGMA foreign_keys = ON` is already in the schema, so CASCADE will work
|
||||
|
||||
### 2. Store Tags: `src/main.c` — New `store_event_tags()` Function
|
||||
|
||||
Add a new function called after successful event INSERT in `store_event()`:
|
||||
|
||||
```c
|
||||
// Insert denormalized tags into event_tags table for fast indexed lookups
|
||||
int store_event_tags(const char* event_id, cJSON* tags) {
|
||||
if (!g_db || !event_id || !tags || !cJSON_IsArray(tags)) {
|
||||
return 0; // Not an error if no tags
|
||||
}
|
||||
|
||||
const char* sql = "INSERT INTO event_tags (event_id, tag_name, tag_value, tag_index) VALUES (?, ?, ?, ?)";
|
||||
sqlite3_stmt* stmt;
|
||||
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
DEBUG_ERROR("Failed to prepare event_tags insert: %s", sqlite3_errmsg(g_db));
|
||||
return -1;
|
||||
}
|
||||
|
||||
int tag_index = 0;
|
||||
cJSON* tag = NULL;
|
||||
cJSON_ArrayForEach(tag, tags) {
|
||||
if (cJSON_IsArray(tag) && cJSON_GetArraySize(tag) >= 2) {
|
||||
cJSON* name = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON* value = cJSON_GetArrayItem(tag, 1);
|
||||
|
||||
if (cJSON_IsString(name) && cJSON_IsString(value)) {
|
||||
sqlite3_reset(stmt);
|
||||
sqlite3_bind_text(stmt, 1, event_id, -1, SQLITE_STATIC);
|
||||
sqlite3_bind_text(stmt, 2, cJSON_GetStringValue(name), -1, SQLITE_STATIC);
|
||||
sqlite3_bind_text(stmt, 3, cJSON_GetStringValue(value), -1, SQLITE_STATIC);
|
||||
sqlite3_bind_int(stmt, 4, tag_index);
|
||||
|
||||
rc = sqlite3_step(stmt);
|
||||
if (rc != SQLITE_DONE) {
|
||||
DEBUG_ERROR("Failed to insert event tag: %s", sqlite3_errmsg(g_db));
|
||||
}
|
||||
}
|
||||
}
|
||||
tag_index++;
|
||||
}
|
||||
|
||||
sqlite3_finalize(stmt);
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
**Call site** — in `store_event()` at line ~784, after `monitoring_on_event_stored()`:
|
||||
|
||||
```c
|
||||
// After successful event storage, insert denormalized tags
|
||||
store_event_tags(cJSON_GetStringValue(id), tags);
|
||||
```
|
||||
|
||||
### 3. Update REQ Query Builder: `src/main.c` — `handle_req_message()`
|
||||
|
||||
Replace the `json_each()` tag filter at lines 1243-1270 with an `event_tags` subquery:
|
||||
|
||||
**Before** (current code at line 1244):
|
||||
```c
|
||||
snprintf(sql_ptr, remaining,
|
||||
" AND EXISTS (SELECT 1 FROM json_each(json(tags)) "
|
||||
"WHERE json_extract(value, '$[0]') = ? "
|
||||
"AND json_extract(value, '$[1]') IN (");
|
||||
```
|
||||
|
||||
**After** (new code):
|
||||
```c
|
||||
snprintf(sql_ptr, remaining,
|
||||
" AND id IN (SELECT event_id FROM event_tags "
|
||||
"WHERE tag_name = ? AND tag_value IN (");
|
||||
```
|
||||
|
||||
The rest of the parameter binding code (lines 1248-1270) stays the same — the bind params are identical (`tag_name` then `tag_value(s)`). Only the SQL template changes.
|
||||
|
||||
The closing parenthesis changes from `"))` to `"))` — same syntax, just different semantics.
|
||||
|
||||
### 4. NIP-09 Delete Cascade: `src/nip009.c`
|
||||
|
||||
The `ON DELETE CASCADE` foreign key handles this automatically. When `DELETE FROM events WHERE id = ?` executes, SQLite will automatically delete matching rows from `event_tags`. **No code changes needed in nip009.c** as long as `PRAGMA foreign_keys = ON` is set (it already is in the schema).
|
||||
|
||||
However, verify the replaceable/addressable event triggers in the schema also cascade properly. The existing triggers at schema lines 97-119 use `DELETE FROM events WHERE ...` which will trigger the CASCADE.
|
||||
|
||||
### 5. Performance Fix: Move Config Reads Out of Row Loop
|
||||
|
||||
In `handle_req_message()` at lines 1400-1401, `get_config_bool()` is called **inside the `while (sqlite3_step())` loop** — meaning it executes a SQLite query for every row returned. Move these before the loop:
|
||||
|
||||
**Before** (inside loop):
|
||||
```c
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
// ...
|
||||
int expiration_enabled = get_config_bool("expiration_enabled", 1); // SQLite query per row!
|
||||
int filter_responses = get_config_bool("expiration_filter", 1); // SQLite query per row!
|
||||
```
|
||||
|
||||
**After** (before loop):
|
||||
```c
|
||||
int expiration_enabled = get_config_bool("expiration_enabled", 1);
|
||||
int filter_responses = get_config_bool("expiration_filter", 1);
|
||||
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
// ... use cached values
|
||||
```
|
||||
|
||||
### 6. Log Level Fixes
|
||||
|
||||
#### NIP-17 Decryption Failure — `src/websockets.c` lines 780 and 1488
|
||||
|
||||
Change from `DEBUG_ERROR` to `DEBUG_INFO`:
|
||||
```c
|
||||
// Before:
|
||||
DEBUG_ERROR("NIP-17 admin message processing failed");
|
||||
// After:
|
||||
DEBUG_INFO("NIP-17 admin message processing failed");
|
||||
```
|
||||
|
||||
This is expected behavior when non-admin gift wraps arrive — not an error condition.
|
||||
|
||||
#### Duplicate Event INSERT — `src/main.c` line 741
|
||||
|
||||
The `DEBUG_ERROR` at line 741 fires before the CONSTRAINT check at line 746. Suppress it for constraint violations:
|
||||
|
||||
```c
|
||||
// Before (line 738-741):
|
||||
if (rc != SQLITE_DONE) {
|
||||
const char* err_msg = sqlite3_errmsg(g_db);
|
||||
int extended_errcode = sqlite3_extended_errcode(g_db);
|
||||
DEBUG_ERROR("INSERT failed: rc=%d, extended_errcode=%d, msg=%s", rc, extended_errcode, err_msg);
|
||||
}
|
||||
|
||||
// After:
|
||||
if (rc != SQLITE_DONE) {
|
||||
const char* err_msg = sqlite3_errmsg(g_db);
|
||||
int extended_errcode = sqlite3_extended_errcode(g_db);
|
||||
if (rc != SQLITE_CONSTRAINT) {
|
||||
DEBUG_ERROR("INSERT failed: rc=%d, extended_errcode=%d, msg=%s", rc, extended_errcode, err_msg);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Startup Tag Population
|
||||
|
||||
Since you said no migration code is needed (fresh deploy), the `event_tags` table will start empty and populate as new events arrive. Existing events won't have tags in the lookup table.
|
||||
|
||||
However, to avoid a period where old events don't appear in tag-filtered queries, add a one-time population function that runs at startup:
|
||||
|
||||
```c
|
||||
// Populate event_tags from existing events (run once at startup)
|
||||
int populate_event_tags_from_existing(void) {
|
||||
if (!g_db) return -1;
|
||||
|
||||
// Check if event_tags is already populated
|
||||
sqlite3_stmt* check_stmt;
|
||||
sqlite3_prepare_v2(g_db, "SELECT COUNT(*) FROM event_tags", -1, &check_stmt, NULL);
|
||||
if (sqlite3_step(check_stmt) == SQLITE_ROW && sqlite3_column_int(check_stmt, 0) > 0) {
|
||||
sqlite3_finalize(check_stmt);
|
||||
DEBUG_INFO("event_tags already populated, skipping");
|
||||
return 0;
|
||||
}
|
||||
sqlite3_finalize(check_stmt);
|
||||
|
||||
DEBUG_INFO("Populating event_tags from existing events...");
|
||||
|
||||
const char* sql = "SELECT id, tags FROM events WHERE tags != '[]'";
|
||||
sqlite3_stmt* stmt;
|
||||
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) return -1;
|
||||
|
||||
// Use a transaction for bulk insert performance
|
||||
sqlite3_exec(g_db, "BEGIN TRANSACTION", NULL, NULL, NULL);
|
||||
|
||||
int event_count = 0;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char* event_id = (const char*)sqlite3_column_text(stmt, 0);
|
||||
const char* tags_json = (const char*)sqlite3_column_text(stmt, 1);
|
||||
|
||||
if (event_id && tags_json) {
|
||||
cJSON* tags = cJSON_Parse(tags_json);
|
||||
if (tags) {
|
||||
store_event_tags(event_id, tags);
|
||||
cJSON_Delete(tags);
|
||||
event_count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sqlite3_finalize(stmt);
|
||||
sqlite3_exec(g_db, "COMMIT", NULL, NULL, NULL);
|
||||
|
||||
DEBUG_INFO("Populated event_tags for %d events", event_count);
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
Call this during startup, after database initialization but before accepting connections.
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph "Current: O(n) per query"
|
||||
A1["REQ with #g filter"] --> B1["SQL: SELECT ... WHERE EXISTS"]
|
||||
B1 --> C1["json_each(json(tags))"]
|
||||
C1 --> D1["json_extract per row"]
|
||||
D1 --> E1["🔴 Full JSON parse per candidate row"]
|
||||
end
|
||||
|
||||
subgraph "New: O(log n) per query"
|
||||
A2["REQ with #g filter"] --> B2["SQL: SELECT ... WHERE id IN"]
|
||||
B2 --> C2["event_tags table"]
|
||||
C2 --> D2["idx_event_tags_lookup"]
|
||||
D2 --> E2["🟢 B-tree index lookup"]
|
||||
end
|
||||
|
||||
subgraph "Write Path (unchanged speed)"
|
||||
F["EVENT arrives"] --> G["store_event()"]
|
||||
G --> H["INSERT INTO events"]
|
||||
H --> I["store_event_tags()"]
|
||||
I --> J["INSERT INTO event_tags\n(one row per tag)"]
|
||||
end
|
||||
|
||||
subgraph "Delete Path (automatic)"
|
||||
K["NIP-09 DELETE"] --> L["DELETE FROM events"]
|
||||
L --> M["ON DELETE CASCADE"]
|
||||
M --> N["event_tags rows auto-deleted"]
|
||||
end
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. Run existing test suite: `tests/run_all_tests.sh`
|
||||
2. Specifically run NIP tests that use tag filters: `tests/run_nip_tests.sh`
|
||||
3. Verify tag-filtered REQ queries return correct results
|
||||
4. Verify NIP-09 deletion cascades to event_tags
|
||||
5. Verify replaceable event triggers cascade to event_tags
|
||||
6. Monitor CPU usage after deployment with same traffic pattern
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- **Low risk**: The `event_tags` table is additive — it doesn't change the events table structure
|
||||
- **Low risk**: `ON DELETE CASCADE` is a well-tested SQLite feature
|
||||
- **Low risk**: The SQL change in `handle_req_message()` is a drop-in replacement (same bind params)
|
||||
- **Medium risk**: Startup population on a large database could take a few seconds — but with only 55K events, this should complete in under 1 second
|
||||
- **Write overhead**: Each event INSERT now also inserts ~5 rows into event_tags — negligible at 56 events/hour
|
||||
@@ -0,0 +1,298 @@
|
||||
# Web of Trust (WoT) Implementation Plan
|
||||
|
||||
## Feature Description
|
||||
|
||||
When enabled, the relay restricts access to pubkeys that the admin follows. The admin's kind 3 (contact list) event is used as the source of truth — all `p` tags in that event become whitelisted pubkeys. The admin themselves is always whitelisted.
|
||||
|
||||
Single config variable `wot_enabled` with three levels:
|
||||
|
||||
| Value | Mode | Effect |
|
||||
|-------|------|--------|
|
||||
| `0` | Off | Open relay — anyone can read and write |
|
||||
| `1` | Write-only | Only followed pubkeys can **publish** events. Anyone can read/subscribe. |
|
||||
| `2` | Full | Only followed pubkeys can **publish AND subscribe**. Requires NIP-42 auth for subscriptions. Eliminates subscription churn from anonymous connections. |
|
||||
|
||||
## How It Works
|
||||
|
||||
### WoT Sync Flow
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Admin publishes kind 3 event\n- contact list -"] --> B{wot_enabled > 0?}
|
||||
B -->|No| C["Store event normally"]
|
||||
B -->|Yes| D["Extract p tags from kind 3"]
|
||||
D --> E["Clear existing WoT whitelist rules"]
|
||||
E --> F["Insert new whitelist rules\nfor each followed pubkey"]
|
||||
F --> G["Enable auth_enabled"]
|
||||
G --> H{wot_enabled == 2?}
|
||||
H -->|Yes| I["Enable nip42_auth_required_subscriptions"]
|
||||
H -->|No| J["Done - write-only restriction"]
|
||||
```
|
||||
|
||||
### Event Publishing Flow — Write Restriction (wot_enabled >= 1)
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["EVENT message arrives"] --> B{auth_enabled?}
|
||||
B -->|No| C["Accept event"]
|
||||
B -->|Yes| D["check_database_auth_rules - pubkey -"]
|
||||
D --> E{Pubkey in whitelist\nor wot_whitelist?}
|
||||
E -->|Yes| C
|
||||
E -->|No| F{Any whitelist rules exist?}
|
||||
F -->|Yes| G["REJECT: not whitelisted"]
|
||||
F -->|No| C
|
||||
```
|
||||
|
||||
### Subscription Flow — Read Restriction (wot_enabled == 2)
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["REQ message arrives"] --> B{wot_enabled == 2?}
|
||||
B -->|No| C["Allow subscription"]
|
||||
B -->|Yes| D{NIP-42 authenticated?}
|
||||
D -->|No| E["Send AUTH challenge"]
|
||||
D -->|Yes| F["check_database_auth_rules\nusing authenticated_pubkey"]
|
||||
F --> G{Pubkey in whitelist\nor wot_whitelist?}
|
||||
G -->|Yes| C
|
||||
G -->|No| H["REJECT: not authorized\nfor subscriptions"]
|
||||
```
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Leveraging Existing Infrastructure
|
||||
|
||||
The relay already has everything needed:
|
||||
|
||||
1. **`auth_rules` table** — stores whitelist/blacklist rules with `rule_type`, `pattern_type`, `pattern_value`
|
||||
2. **`check_database_auth_rules()`** in [`request_validator.c:529`](src/request_validator.c:529) — already implements the logic: "if whitelist rules exist and pubkey is not whitelisted, deny"
|
||||
3. **`add_auth_rule_from_config()`** / **`remove_auth_rule_from_config()`** in [`config.c`](src/config.c:2082) — already manage auth rules
|
||||
4. **`event_tags` table** — can efficiently query `p` tags from kind 3 events
|
||||
5. **Config system** — `auth_enabled` flag already controls whether auth rules are checked
|
||||
6. **NIP-42 auth** — `nip42_auth_required_subscriptions` already gates REQ access, `pss->authenticated_pubkey` stores the authenticated pubkey
|
||||
|
||||
### WoT-Specific Whitelist Rules
|
||||
|
||||
To distinguish WoT-generated whitelist rules from manually-added ones, we use a new `rule_type` value: `wot_whitelist`. This allows:
|
||||
- Clearing all WoT rules without affecting manual whitelist/blacklist rules
|
||||
- Querying WoT status separately
|
||||
- The existing `check_database_auth_rules()` function already checks for `rule_type = 'whitelist'` — we need to also match `wot_whitelist` in the whitelist check
|
||||
|
||||
### Trigger Mechanism
|
||||
|
||||
The WoT sync happens when:
|
||||
1. **A kind 3 event from the admin is stored** — detected in `store_event()` after successful INSERT
|
||||
2. **Startup** — if `wot_enabled > 0`, sync from the most recent admin kind 3 event in the database
|
||||
3. **Admin DM command** — `wot sync` to force a manual resync
|
||||
|
||||
### What Gets Whitelisted
|
||||
|
||||
- All pubkeys in `p` tags of the admin's kind 3 event
|
||||
- The admin pubkey itself (always whitelisted)
|
||||
- The relay pubkey (always whitelisted — for admin DM responses)
|
||||
|
||||
### What Is NOT Blocked
|
||||
|
||||
Even with WoT enabled, these are always allowed:
|
||||
- Admin events (kind 23456) — already bypassed in [`request_validator.c:303`](src/request_validator.c:303)
|
||||
- NIP-42 auth events (kind 22242) — already bypassed in [`request_validator.c:318`](src/request_validator.c:318)
|
||||
- Kind 3 events from the admin — needed to update the follow list itself
|
||||
- Kind 1059 gift wraps addressed to the relay — needed for admin DMs
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `src/default_config_event.h` | Add `wot_enabled` config key (default: `0`) |
|
||||
| `src/config.c` | Add `wot_sync_from_admin_kind3()` function |
|
||||
| `src/main.c` | Add WoT trigger in `store_event()` when admin kind 3 is stored, add startup WoT sync |
|
||||
| `src/request_validator.c` | Update whitelist SQL to also match `wot_whitelist` rule type |
|
||||
| `src/sql_schema.h` | Update `auth_rules` CHECK constraint to include `wot_whitelist` |
|
||||
| `src/websockets.c` | Add WoT pubkey check after NIP-42 auth check in REQ handler |
|
||||
| `src/dm_admin.c` | Add `wot 0`, `wot 1`, `wot 2`, `wot sync`, `wot status` DM commands |
|
||||
|
||||
## Detailed Changes
|
||||
|
||||
### 1. Schema: `src/sql_schema.h`
|
||||
|
||||
Update the `auth_rules` table CHECK constraint to allow `wot_whitelist`:
|
||||
|
||||
```sql
|
||||
-- Before:
|
||||
rule_type TEXT NOT NULL CHECK (rule_type IN ('whitelist', 'blacklist', 'rate_limit', 'auth_required'))
|
||||
|
||||
-- After:
|
||||
rule_type TEXT NOT NULL CHECK (rule_type IN ('whitelist', 'blacklist', 'rate_limit', 'auth_required', 'wot_whitelist'))
|
||||
```
|
||||
|
||||
### 2. Config: `src/default_config_event.h`
|
||||
|
||||
Add WoT configuration:
|
||||
|
||||
```c
|
||||
// Web of Trust Settings
|
||||
// 0 = off, 1 = write-only (followed pubkeys can publish), 2 = full (followed pubkeys can publish AND subscribe)
|
||||
{"wot_enabled", "0"},
|
||||
```
|
||||
|
||||
### 3. Core WoT Sync Function: `src/config.c`
|
||||
|
||||
New function `wot_sync_from_admin_kind3()`:
|
||||
|
||||
```c
|
||||
int wot_sync_from_admin_kind3(void) {
|
||||
int wot_level = get_config_int("wot_enabled", 0);
|
||||
if (wot_level <= 0) return 0; // WoT disabled
|
||||
|
||||
// 1. Get admin pubkey from config
|
||||
// 2. Query event_tags for p tags from admin's latest kind 3 event:
|
||||
// SELECT DISTINCT et.tag_value FROM event_tags et
|
||||
// JOIN events e ON et.event_id = e.id
|
||||
// WHERE e.kind = 3 AND e.pubkey = ? AND et.tag_name = 'p'
|
||||
// ORDER BY e.created_at DESC
|
||||
// 3. BEGIN TRANSACTION
|
||||
// 4. DELETE FROM auth_rules WHERE rule_type = 'wot_whitelist'
|
||||
// 5. INSERT wot_whitelist for admin pubkey
|
||||
// 6. INSERT wot_whitelist for relay pubkey
|
||||
// 7. For each p tag: INSERT INTO auth_rules (rule_type, pattern_type, pattern_value)
|
||||
// VALUES ('wot_whitelist', 'pubkey', ?)
|
||||
// 8. COMMIT
|
||||
// 9. Set auth_enabled = true
|
||||
// 10. If wot_level == 2: set nip42_auth_required_subscriptions = true
|
||||
// 11. Log count of whitelisted pubkeys
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Trigger on Kind 3 Store: `src/main.c`
|
||||
|
||||
In `store_event()`, after successful INSERT and after `store_event_tags()`:
|
||||
|
||||
```c
|
||||
// Check if this is a kind 3 event from the admin — trigger WoT sync
|
||||
if ((int)cJSON_GetNumberValue(kind) == 3) {
|
||||
int wot_level = get_config_int("wot_enabled", 0);
|
||||
if (wot_level > 0) {
|
||||
const char* admin_pubkey = get_config_value("admin_pubkey");
|
||||
if (admin_pubkey && strcmp(cJSON_GetStringValue(pubkey), admin_pubkey) == 0) {
|
||||
DEBUG_INFO("Admin kind 3 event stored — triggering WoT sync");
|
||||
wot_sync_from_admin_kind3();
|
||||
}
|
||||
if (admin_pubkey) free((char*)admin_pubkey);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Startup WoT Sync: `src/main.c`
|
||||
|
||||
In `main()`, after `populate_event_tags_from_existing()`:
|
||||
|
||||
```c
|
||||
// Sync Web of Trust whitelist if enabled
|
||||
int wot_level = get_config_int("wot_enabled", 0);
|
||||
if (wot_level > 0) {
|
||||
wot_sync_from_admin_kind3();
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Update Whitelist Check: `src/request_validator.c`
|
||||
|
||||
Update the whitelist SQL queries to also match `wot_whitelist`:
|
||||
|
||||
```c
|
||||
// Before:
|
||||
"SELECT rule_type FROM auth_rules WHERE rule_type = 'whitelist' AND pattern_type = 'pubkey' AND pattern_value = ? AND active = 1 LIMIT 1"
|
||||
|
||||
// After:
|
||||
"SELECT rule_type FROM auth_rules WHERE rule_type IN ('whitelist', 'wot_whitelist') AND pattern_type = 'pubkey' AND pattern_value = ? AND active = 1 LIMIT 1"
|
||||
```
|
||||
|
||||
And the whitelist-exists check:
|
||||
```c
|
||||
// Before:
|
||||
"SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'whitelist' AND pattern_type = 'pubkey' AND active = 1 LIMIT 1"
|
||||
|
||||
// After:
|
||||
"SELECT COUNT(*) FROM auth_rules WHERE rule_type IN ('whitelist', 'wot_whitelist') AND pattern_type = 'pubkey' AND active = 1 LIMIT 1"
|
||||
```
|
||||
|
||||
### 7. REQ Handler WoT Check: `src/websockets.c`
|
||||
|
||||
When `wot_enabled == 2`, add a pubkey whitelist check **after** the existing NIP-42 auth check in the REQ handler. The existing code at line 903 already requires NIP-42 auth when `nip42_auth_required_subscriptions` is set. We add a WoT check right after:
|
||||
|
||||
```c
|
||||
// Existing NIP-42 auth check (line 903):
|
||||
if (pss && pss->nip42_auth_required_subscriptions && !pss->authenticated) {
|
||||
// ... send AUTH challenge or NOTICE ...
|
||||
return 0;
|
||||
}
|
||||
|
||||
// NEW: WoT read restriction check (wot_enabled == 2)
|
||||
if (pss && pss->authenticated && get_config_int("wot_enabled", 0) == 2) {
|
||||
// Client is authenticated — check if their pubkey is in the WoT whitelist
|
||||
int wot_result = check_database_auth_rules(pss->authenticated_pubkey, "subscription", NULL);
|
||||
if (wot_result != NOSTR_SUCCESS) {
|
||||
send_notice_message(wsi, pss, "restricted: your pubkey is not in this relay's web of trust");
|
||||
DEBUG_INFO("REQ rejected: pubkey %s not in WoT whitelist", pss->authenticated_pubkey);
|
||||
cJSON_Delete(json);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Admin DM Commands: `src/dm_admin.c`
|
||||
|
||||
Add plain text DM commands:
|
||||
|
||||
| Command | Action |
|
||||
|---------|--------|
|
||||
| `wot 0` or `wot off` | Disable WoT, delete all `wot_whitelist` rules, reset `nip42_auth_required_subscriptions` |
|
||||
| `wot 1` or `wot write` | Write-only WoT — sync follow list, set `auth_enabled=true` |
|
||||
| `wot 2` or `wot full` | Full WoT — sync follow list, set `auth_enabled=true`, set `nip42_auth_required_subscriptions=true` |
|
||||
| `wot sync` | Force resync from admin's kind 3 event |
|
||||
| `wot status` | Show WoT level (0/1/2), count of whitelisted pubkeys |
|
||||
|
||||
### 9. NIP-11 Update
|
||||
|
||||
When WoT is enabled (level 1 or 2), the relay should indicate this in NIP-11 relay info. Add to the `limitation` object:
|
||||
```json
|
||||
"auth_required": true
|
||||
```
|
||||
|
||||
## Edge Cases
|
||||
|
||||
1. **Admin has no kind 3 event in database**: WoT sync does nothing, logs a warning. No whitelist rules created = open relay.
|
||||
|
||||
2. **Admin updates follow list**: New kind 3 event triggers full resync — old WoT rules are cleared, new ones inserted. This is atomic (transaction).
|
||||
|
||||
3. **WoT disabled (set to 0)**: Clears all `wot_whitelist` rules. Manual `whitelist` rules are preserved. `nip42_auth_required_subscriptions` is reset to false.
|
||||
|
||||
4. **Admin unfollows someone**: Next kind 3 event triggers resync, the unfollowed pubkey's `wot_whitelist` rule is removed (full clear + reinsert).
|
||||
|
||||
5. **Kind 1059 gift wraps**: These need special handling — the relay needs to accept gift wraps addressed to it even from non-whitelisted pubkeys (for admin DMs). The `is_nip17_gift_wrap_for_relay()` check should bypass WoT.
|
||||
|
||||
6. **Read restriction without NIP-42 support (level 2)**: If a client doesn't support NIP-42, they cannot authenticate and therefore cannot subscribe. This is by design — it's the most effective way to reduce subscription churn from anonymous connections.
|
||||
|
||||
7. **NIP-11 discovery**: Clients can check NIP-11 to see if `auth_required` is true before connecting, avoiding wasted connections.
|
||||
|
||||
8. **Changing level from 2 to 1**: `nip42_auth_required_subscriptions` is reset to false, allowing anonymous subscriptions again. WoT whitelist rules remain for write restriction.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. Enable write-only WoT via DM: `wot 1`
|
||||
2. Verify admin can still publish events
|
||||
3. Verify followed pubkeys can publish events
|
||||
4. Verify non-followed pubkeys are rejected for EVENT
|
||||
5. Verify non-followed pubkeys can still subscribe (REQ)
|
||||
6. Enable full WoT via DM: `wot 2`
|
||||
7. Verify unauthenticated clients get AUTH challenge on REQ
|
||||
8. Verify authenticated non-followed pubkeys are rejected for REQ
|
||||
9. Verify authenticated followed pubkeys can subscribe
|
||||
10. Verify `wot status` shows correct level and count
|
||||
11. Publish new kind 3 event, verify auto-resync
|
||||
12. `wot 0` — verify all pubkeys can publish and subscribe again
|
||||
13. Verify admin DMs still work when WoT is enabled
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- WoT sync is O(n) where n = number of follows (typically 100-2000)
|
||||
- Uses a transaction for bulk insert — fast even for large follow lists
|
||||
- Auth rule check is already indexed: `idx_auth_rules_pattern ON auth_rules(pattern_type, pattern_value)`
|
||||
- No additional per-event overhead — the existing `check_database_auth_rules()` already runs on every event when auth is enabled
|
||||
- **Level 2 directly addresses CPU load**: With `wot_enabled=2`, unauthenticated connections cannot create subscriptions, eliminating the ~120 REQ/minute churn from anonymous connections that was causing 99% CPU
|
||||
@@ -0,0 +1,388 @@
|
||||
# Web of Trust — Admin Web UI Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Add a **Web of Trust** section to the existing Authorization page in the admin web interface. The section lets the admin see whether their kind 3 contact list is on the relay, select a WoT level, trigger a sync, and view the current whitelist count.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Web UI] -->|config_set wot_enabled N| B[Kind 23456 Event]
|
||||
A -->|system_command wot_sync| B
|
||||
A -->|system_command wot_status| B
|
||||
B --> C[Relay Backend]
|
||||
C -->|Kind 23457 Response| D[Web UI updates display]
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **On page load** — the Authorization page already calls `loadAuthRules`. We add a parallel call to `loadWotStatus` which sends a `system_command` / `wot_status` event.
|
||||
2. **Backend responds** with a kind 23457 JSON containing: `wot_enabled` level, `wot_whitelist_count`, and `admin_kind3_exists` boolean.
|
||||
3. **UI renders** a card showing:
|
||||
- Whether the admin's kind 3 event exists on the relay
|
||||
- Current WoT level as a 3-option radio/button group
|
||||
- Count of whitelisted pubkeys
|
||||
- A SYNC button to force resync
|
||||
4. **Changing level** sends `config_set` / `wot_enabled` / `0|1|2` via the existing admin API, then triggers `wot_sync` if level > 0.
|
||||
5. **SYNC button** sends `system_command` / `wot_sync`.
|
||||
|
||||
## Detailed Changes
|
||||
|
||||
### 1. Backend: New system commands in `src/config.c`
|
||||
|
||||
Add two new branches to `handle_system_command_unified`:
|
||||
|
||||
#### `wot_status` command
|
||||
|
||||
Returns JSON response:
|
||||
```json
|
||||
{
|
||||
"command": "wot_status",
|
||||
"status": "success",
|
||||
"wot_enabled": 2,
|
||||
"admin_kind3_exists": true,
|
||||
"wot_whitelist_count": 347,
|
||||
"timestamp": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
Implementation:
|
||||
- Read `wot_enabled` from config table
|
||||
- Query `SELECT COUNT(*) FROM events WHERE kind = 3 AND pubkey = ?` with admin_pubkey to check kind 3 existence
|
||||
- Query `SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'wot_whitelist' AND active = 1` for whitelist count
|
||||
- Return as kind 23457 signed response
|
||||
|
||||
#### `wot_sync` command
|
||||
|
||||
Calls `wot_sync_from_admin_kind3` and returns result:
|
||||
```json
|
||||
{
|
||||
"command": "wot_sync",
|
||||
"status": "success",
|
||||
"wot_whitelist_count": 347,
|
||||
"timestamp": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Backend: Hook `config_set` for `wot_enabled`
|
||||
|
||||
In `handle_config_set_unified`, after the config value is updated, add a check:
|
||||
```c
|
||||
// After successful update, trigger WoT sync if wot_enabled changed
|
||||
if (strcmp(config_key, "wot_enabled") == 0) {
|
||||
int new_level = atoi(config_value);
|
||||
if (new_level > 0) {
|
||||
wot_sync_from_admin_kind3();
|
||||
} else {
|
||||
// Level 0: clear wot_whitelist rules and reset auth flags
|
||||
sqlite3_exec(g_db, "DELETE FROM auth_rules WHERE rule_type = 'wot_whitelist'", NULL, NULL, NULL);
|
||||
update_config_in_table("nip42_auth_required_subscriptions", "false");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Frontend: HTML — WoT section in `api/index.html`
|
||||
|
||||
Add a new div **inside** the `authRulesSection`, before the auth rules table. This keeps WoT visually grouped with authorization:
|
||||
|
||||
```html
|
||||
<!-- Web of Trust Section -->
|
||||
<div id="wotSection" class="input-group">
|
||||
<div class="section-header" style="font-size: 14px; margin-bottom: 10px;">
|
||||
WEB OF TRUST
|
||||
</div>
|
||||
|
||||
<!-- Kind 3 Status Indicator -->
|
||||
<div id="wotKind3Status" class="wot-status-row">
|
||||
<span>Admin Contact List (kind 3):</span>
|
||||
<span id="wotKind3Indicator" class="wot-indicator wot-unknown">Checking...</span>
|
||||
</div>
|
||||
|
||||
<!-- WoT Level Selector -->
|
||||
<div class="wot-level-selector">
|
||||
<label>WoT Level:</label>
|
||||
<div class="inline-buttons">
|
||||
<button type="button" id="wotLevel0Btn" class="wot-level-btn" onclick="setWotLevel(0)">
|
||||
OFF
|
||||
</button>
|
||||
<button type="button" id="wotLevel1Btn" class="wot-level-btn" onclick="setWotLevel(1)">
|
||||
WRITE ONLY
|
||||
</button>
|
||||
<button type="button" id="wotLevel2Btn" class="wot-level-btn" onclick="setWotLevel(2)">
|
||||
FULL
|
||||
</button>
|
||||
</div>
|
||||
<div class="wot-level-description" id="wotLevelDescription">
|
||||
Level 0: Open relay — anyone can read and write
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WoT Stats -->
|
||||
<div class="wot-stats-row">
|
||||
<span>Whitelisted Pubkeys:</span>
|
||||
<span id="wotWhitelistCount">—</span>
|
||||
</div>
|
||||
|
||||
<!-- Sync Button -->
|
||||
<div class="inline-buttons">
|
||||
<button type="button" id="wotSyncBtn" onclick="syncWot()">SYNC FROM KIND 3</button>
|
||||
<button type="button" id="wotRefreshBtn" onclick="loadWotStatus()">REFRESH STATUS</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr style="margin: 15px 0; border-color: var(--border-color);">
|
||||
```
|
||||
|
||||
### 4. Frontend: CSS additions in `api/index.css`
|
||||
|
||||
```css
|
||||
/* Web of Trust styles */
|
||||
.wot-status-row, .wot-stats-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.wot-indicator {
|
||||
padding: 2px 10px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wot-indicator.wot-found { background: #28a745; color: white; }
|
||||
.wot-indicator.wot-missing { background: #dc3545; color: white; }
|
||||
.wot-indicator.wot-unknown { background: #6c757d; color: white; }
|
||||
|
||||
.wot-level-selector { padding: 10px 0; }
|
||||
.wot-level-selector label { display: block; margin-bottom: 5px; font-weight: bold; }
|
||||
|
||||
.wot-level-btn { min-width: 100px; }
|
||||
.wot-level-btn.active {
|
||||
background: var(--accent-color, #007bff);
|
||||
color: white;
|
||||
border-color: var(--accent-color, #007bff);
|
||||
}
|
||||
|
||||
.wot-level-description {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 5px;
|
||||
font-style: italic;
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Frontend: JavaScript functions in `api/index.js`
|
||||
|
||||
#### `loadWotStatus` — query current WoT state
|
||||
|
||||
```javascript
|
||||
async function loadWotStatus() {
|
||||
try {
|
||||
if (!isLoggedIn || !userPubkey || !relayPool) return;
|
||||
|
||||
const command_array = ["system_command", "wot_status"];
|
||||
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
|
||||
if (!encrypted_content) return;
|
||||
|
||||
const event = {
|
||||
kind: 23456,
|
||||
pubkey: userPubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [["p", getRelayPubkey()]],
|
||||
content: encrypted_content
|
||||
};
|
||||
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
const url = relayConnectionUrl.value.trim();
|
||||
await relayPool.publish([url], signedEvent);
|
||||
|
||||
log('WoT status query sent', 'INFO');
|
||||
} catch (error) {
|
||||
log('Failed to load WoT status: ' + error.message, 'ERROR');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `setWotLevel` — change WoT level via config_set
|
||||
|
||||
```javascript
|
||||
async function setWotLevel(level) {
|
||||
try {
|
||||
if (!isLoggedIn || !userPubkey || !relayPool) return;
|
||||
|
||||
// Send config_set for wot_enabled
|
||||
const command_array = ["config_set", "wot_enabled", String(level)];
|
||||
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
|
||||
if (!encrypted_content) return;
|
||||
|
||||
const event = {
|
||||
kind: 23456,
|
||||
pubkey: userPubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [["p", getRelayPubkey()]],
|
||||
content: encrypted_content
|
||||
};
|
||||
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
const url = relayConnectionUrl.value.trim();
|
||||
await relayPool.publish([url], signedEvent);
|
||||
|
||||
log('WoT level set to ' + level, 'INFO');
|
||||
|
||||
// Refresh status after a short delay
|
||||
setTimeout(() => loadWotStatus(), 1500);
|
||||
} catch (error) {
|
||||
log('Failed to set WoT level: ' + error.message, 'ERROR');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `syncWot` — force WoT sync
|
||||
|
||||
```javascript
|
||||
async function syncWot() {
|
||||
try {
|
||||
if (!isLoggedIn || !userPubkey || !relayPool) return;
|
||||
|
||||
const command_array = ["system_command", "wot_sync"];
|
||||
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
|
||||
if (!encrypted_content) return;
|
||||
|
||||
const event = {
|
||||
kind: 23456,
|
||||
pubkey: userPubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [["p", getRelayPubkey()]],
|
||||
content: encrypted_content
|
||||
};
|
||||
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
const url = relayConnectionUrl.value.trim();
|
||||
await relayPool.publish([url], signedEvent);
|
||||
|
||||
log('WoT sync command sent', 'INFO');
|
||||
|
||||
// Refresh status after sync completes
|
||||
setTimeout(() => loadWotStatus(), 2000);
|
||||
} catch (error) {
|
||||
log('Failed to sync WoT: ' + error.message, 'ERROR');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `handleWotStatusResponse` — process backend response
|
||||
|
||||
```javascript
|
||||
function handleWotStatusResponse(responseData) {
|
||||
const kind3Indicator = document.getElementById('wotKind3Indicator');
|
||||
const whitelistCount = document.getElementById('wotWhitelistCount');
|
||||
const levelDesc = document.getElementById('wotLevelDescription');
|
||||
|
||||
// Update kind 3 indicator
|
||||
if (kind3Indicator) {
|
||||
if (responseData.admin_kind3_exists) {
|
||||
kind3Indicator.textContent = 'Found ✓';
|
||||
kind3Indicator.className = 'wot-indicator wot-found';
|
||||
} else {
|
||||
kind3Indicator.textContent = 'Not Found ✗';
|
||||
kind3Indicator.className = 'wot-indicator wot-missing';
|
||||
}
|
||||
}
|
||||
|
||||
// Update whitelist count
|
||||
if (whitelistCount) {
|
||||
whitelistCount.textContent = responseData.wot_whitelist_count || 0;
|
||||
}
|
||||
|
||||
// Update level buttons
|
||||
const level = responseData.wot_enabled || 0;
|
||||
['wotLevel0Btn', 'wotLevel1Btn', 'wotLevel2Btn'].forEach((id, i) => {
|
||||
const btn = document.getElementById(id);
|
||||
if (btn) {
|
||||
btn.classList.toggle('active', i === level);
|
||||
}
|
||||
});
|
||||
|
||||
// Update description
|
||||
const descriptions = [
|
||||
'Level 0: Open relay — anyone can read and write',
|
||||
'Level 1: Write-only — only followed pubkeys can publish events',
|
||||
'Level 2: Full — only followed pubkeys can publish AND subscribe (NIP-42 required)'
|
||||
];
|
||||
if (levelDesc) {
|
||||
levelDesc.textContent = descriptions[level] || descriptions[0];
|
||||
}
|
||||
|
||||
// Enable/disable sync button based on kind 3 existence
|
||||
const syncBtn = document.getElementById('wotSyncBtn');
|
||||
if (syncBtn) {
|
||||
syncBtn.disabled = !responseData.admin_kind3_exists;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Wire into existing response handler
|
||||
|
||||
In the existing `handleSystemCommandResponse` function, add:
|
||||
```javascript
|
||||
if (responseData.command === 'wot_status' || responseData.command === 'wot_sync') {
|
||||
handleWotStatusResponse(responseData);
|
||||
}
|
||||
```
|
||||
|
||||
#### Wire into page navigation
|
||||
|
||||
In the `authorization` case of the page switch handler, add:
|
||||
```javascript
|
||||
case 'authorization':
|
||||
loadAuthRules().catch(...);
|
||||
loadWotStatus().catch(error => {
|
||||
console.log('Auto-load WoT status failed: ' + error.message);
|
||||
});
|
||||
break;
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `src/config.c` | Add `wot_status` and `wot_sync` branches to `handle_system_command_unified`; add WoT sync hook to `handle_config_set_unified` |
|
||||
| `api/index.html` | Add WoT section HTML inside `authRulesSection` |
|
||||
| `api/index.css` | Add WoT-specific CSS styles |
|
||||
| `api/index.js` | Add `loadWotStatus`, `setWotLevel`, `syncWot`, `handleWotStatusResponse` functions; wire into page nav and response handler |
|
||||
|
||||
## Edge Cases
|
||||
|
||||
1. **Admin not logged in** — WoT section shows but buttons are disabled; status shows "Checking..."
|
||||
2. **No kind 3 event** — Indicator shows red "Not Found ✗"; SYNC button is disabled; level selector still works but sync will whitelist only admin + relay
|
||||
3. **Level change from 2 to 0** — Backend clears all wot_whitelist rules and resets nip42_auth_required_subscriptions
|
||||
4. **Concurrent config_set and wot_sync** — The backend handles these sequentially via SQLite transactions; no race condition
|
||||
|
||||
## UI Mockup
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ WEB OF TRUST │
|
||||
│ │
|
||||
│ Admin Contact List (kind 3): [Found ✓] │
|
||||
│ │
|
||||
│ WoT Level: │
|
||||
│ [ OFF ] [ WRITE ONLY ] [ FULL ] │
|
||||
│ Level 2: Full — only followed pubkeys can │
|
||||
│ publish AND subscribe (NIP-42 required) │
|
||||
│ │
|
||||
│ Whitelisted Pubkeys: 347 │
|
||||
│ │
|
||||
│ [ SYNC FROM KIND 3 ] [ REFRESH STATUS ] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ ─────────────────────────────────────────── │
|
||||
│ AUTH RULES MANAGEMENT │
|
||||
│ ... existing auth rules table ... │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -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
@@ -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
@@ -116,7 +116,10 @@ cJSON* build_query_response(const char* query_type, cJSON* results_array, int to
|
||||
int add_auth_rule_from_config(const char* rule_type, const char* pattern_type,
|
||||
const char* pattern_value);
|
||||
int remove_auth_rule_from_config(const char* rule_type, const char* pattern_type,
|
||||
const char* pattern_value);
|
||||
const char* pattern_value);
|
||||
|
||||
// Web of Trust (WoT) sync function
|
||||
int wot_sync_from_admin_kind3(void);
|
||||
|
||||
// Unified configuration cache management
|
||||
void force_config_cache_refresh(void);
|
||||
|
||||
@@ -83,11 +83,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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user