Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bf02702c2 | ||
|
|
0920cc092d | ||
|
|
a89460be5d | ||
|
|
ff2a3aa335 | ||
|
|
94b61e8a7c | ||
|
|
b0c0754e83 | ||
|
|
3265e3d114 | ||
|
|
927659ece1 | ||
|
|
b6ff4150b4 | ||
|
|
63bc526163 | ||
|
|
a1f712236a | ||
|
|
06e6c17b7b | ||
|
|
0751a7c55c | ||
|
|
f1728932a9 | ||
|
|
ef8bdef2a8 | ||
|
|
c11a8ba292 | ||
|
|
0f124fe575 | ||
|
|
6b20452fab | ||
|
|
10c19bc243 | ||
|
|
3d7aa2196f | ||
|
|
a416c3f275 | ||
|
|
bba9baabc3 | ||
|
|
31187c4c4f | ||
|
|
55f862b879 | ||
|
|
76c9b3fcf0 | ||
|
|
929bd09164 | ||
|
|
d17f1dd8d5 | ||
|
|
207a949835 | ||
|
|
d08f4e4221 | ||
|
|
c96736fa6a | ||
|
|
f8ec4ae924 | ||
|
|
fe7304ac7f | ||
|
|
e5d39c984b | ||
|
|
5e45f21e35 | ||
|
|
5321a238b8 | ||
|
|
083bc14972 | ||
|
|
11aaccba9b | ||
|
|
bd1bbd763d | ||
|
|
2bd7aa5a10 | ||
|
|
361912ec85 | ||
|
|
0de491382e | ||
|
|
3148bbbee7 | ||
|
|
3965ba04d8 | ||
|
|
b96af938bd | ||
|
|
89c8248013 | ||
|
|
d8f477c6cf | ||
|
|
040eeadb13 | ||
|
|
83f8b0ab88 | ||
|
|
b82f7eaaf3 | ||
|
|
0dc5b75d7c | ||
|
|
e94b1a81e3 | ||
|
|
1adabdbc4e | ||
|
|
81d44c3d8c |
@@ -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
|
||||
|
||||
|
||||
+191
@@ -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);
|
||||
}
|
||||
@@ -1308,3 +1486,16 @@ body.dark-mode .sql-results-table tbody tr:nth-child(even) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
/* ================================
|
||||
IP BANS TABLE - compact rows
|
||||
================================ */
|
||||
#ip-bans-table td, #ip-bans-table th {
|
||||
padding: 4px 8px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
}
|
||||
#ip-bans-table button {
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
+173
-13
@@ -16,6 +16,7 @@
|
||||
<li><button class="nav-item" data-page="subscriptions">Subscriptions</button></li>
|
||||
<li><button class="nav-item" data-page="configuration">Configuration</button></li>
|
||||
<li><button class="nav-item" data-page="authorization">Authorization</button></li>
|
||||
<li><button class="nav-item" data-page="ip-bans">IP BANS</button></li>
|
||||
<li><button class="nav-item" data-page="relay-events">Relay Events</button></li>
|
||||
<li><button class="nav-item" data-page="dm">DM</button></li>
|
||||
<li><button class="nav-item" data-page="database">Database Query</button></li>
|
||||
@@ -67,6 +68,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access Denied Overlay (shown when non-admin user logs in) -->
|
||||
<div id="access-denied-overlay" class="access-denied-overlay" style="display: none;">
|
||||
<div class="access-denied-content">
|
||||
<div class="access-denied-icon">⛔</div>
|
||||
<h2>ACCESS DENIED</h2>
|
||||
<p class="access-denied-message">This interface is restricted to the relay administrator.</p>
|
||||
<p class="access-denied-submessage">The logged-in account does not have admin privileges.</p>
|
||||
<button type="button" class="access-denied-logout-btn" onclick="logout()">LOGOUT</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DATABASE STATISTICS Section -->
|
||||
<!-- Subscribe to kind 24567 events to receive real-time monitoring data -->
|
||||
<div class="section flex-section" id="databaseStatisticsSection" style="display: none;">
|
||||
@@ -100,6 +112,10 @@
|
||||
<td>Process ID</td>
|
||||
<td id="process-id">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>WebSocket Connections</td>
|
||||
<td id="websocket-connections">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Active Subscriptions</td>
|
||||
<td id="active-subscriptions">-</td>
|
||||
@@ -108,14 +124,14 @@
|
||||
<td>Memory Usage</td>
|
||||
<td id="memory-usage">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>CPU Core</td>
|
||||
<td id="cpu-core">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>CPU Usage</td>
|
||||
<td id="cpu-usage">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>CPU Core</td>
|
||||
<td id="cpu-core">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Oldest Event</td>
|
||||
<td id="oldest-event">-</td>
|
||||
@@ -251,6 +267,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 +290,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 +308,51 @@ AUTH RULES MANAGEMENT
|
||||
BLACKLIST</button>
|
||||
<button type="button" id="refreshAuthRulesBtn">REFRESH</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- WEB OF TRUST SECTION -->
|
||||
<div class="section flex-section" id="wotSection" style="display: none;">
|
||||
<div class="section-header">
|
||||
WEB OF TRUST
|
||||
</div>
|
||||
|
||||
<!-- Kind 3 Status Indicator -->
|
||||
<div id="wotKind3Status" class="wot-status-row">
|
||||
<span>Admin Contact List (kind 3):</span>
|
||||
<span id="wotKind3Indicator" class="wot-indicator wot-unknown">Checking...</span>
|
||||
</div>
|
||||
|
||||
<!-- WoT Level Selector -->
|
||||
<div class="wot-level-selector">
|
||||
<label>WoT Level:</label>
|
||||
<div class="inline-buttons">
|
||||
<button type="button" id="wotLevel0Btn" class="wot-level-btn" onclick="setWotLevel(0)">
|
||||
OFF
|
||||
</button>
|
||||
<button type="button" id="wotLevel1Btn" class="wot-level-btn" onclick="setWotLevel(1)">
|
||||
WRITE ONLY
|
||||
</button>
|
||||
<button type="button" id="wotLevel2Btn" class="wot-level-btn" onclick="setWotLevel(2)">
|
||||
FULL
|
||||
</button>
|
||||
</div>
|
||||
<div class="wot-level-description" id="wotLevelDescription">
|
||||
Level 0: Open relay — anyone can read and write
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WoT Stats -->
|
||||
<div class="wot-stats-row">
|
||||
<span>Whitelisted Pubkeys:</span>
|
||||
<span id="wotWhitelistCount">—</span>
|
||||
</div>
|
||||
|
||||
<!-- Sync Button -->
|
||||
<div class="inline-buttons">
|
||||
<button type="button" id="wotSyncBtn" onclick="syncWot()">SYNC FROM KIND 3</button>
|
||||
<button type="button" id="wotRefreshBtn" onclick="loadWotStatus()">REFRESH STATUS</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -334,6 +387,109 @@ AUTH RULES MANAGEMENT
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IP BANS Section -->
|
||||
<div class="section" id="ipBansSection" style="display: none;">
|
||||
<div class="section-header">
|
||||
IP BAN MANAGEMENT
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="input-group">
|
||||
<div class="config-table-container">
|
||||
<table class="config-table" id="ip-bans-stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Total IPs Tracked</th>
|
||||
<th>Currently Banned</th>
|
||||
<th>Total Bans Issued</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td id="ip-bans-total">-</td>
|
||||
<td id="ip-bans-active">-</td>
|
||||
<td id="ip-bans-issued">-</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Ban Form -->
|
||||
<div class="input-group">
|
||||
<h3>Manually Ban IP Address</h3>
|
||||
<div class="form-group">
|
||||
<label for="ban-ip-input">IP Address:</label>
|
||||
<input type="text" id="ban-ip-input" placeholder="192.168.1.100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ban-duration-select">Ban Duration:</label>
|
||||
<select id="ban-duration-select">
|
||||
<option value="3600">1 Hour</option>
|
||||
<option value="86400" selected>24 Hours</option>
|
||||
<option value="604800">7 Days</option>
|
||||
<option value="2592000">30 Days</option>
|
||||
<option value="31536000">1 Year</option>
|
||||
<option value="999999999">Permanent</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="inline-buttons">
|
||||
<button type="button" id="add-ban-btn">BAN IP</button>
|
||||
</div>
|
||||
<div id="add-ban-status" class="status-message"></div>
|
||||
</div>
|
||||
|
||||
<!-- Whitelist Management -->
|
||||
<div class="input-group">
|
||||
<h3>IP Whitelist (Never Banned)</h3>
|
||||
<p style="font-size:13px;opacity:0.8;">IPs in this list are never idle-banned. Comma-separated.</p>
|
||||
<div class="form-group">
|
||||
<label for="whitelist-ip-input">Add IP to Whitelist:</label>
|
||||
<input type="text" id="whitelist-ip-input" placeholder="103.81.231.220">
|
||||
</div>
|
||||
<div class="inline-buttons">
|
||||
<button type="button" id="add-whitelist-btn">ADD TO WHITELIST</button>
|
||||
</div>
|
||||
<div id="whitelist-status" class="status-message"></div>
|
||||
<div id="whitelist-current" style="margin-top:8px;font-size:13px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Controls -->
|
||||
<div class="input-group">
|
||||
<div class="inline-buttons">
|
||||
<button type="button" id="ip-ban-filter-all" class="active">All IPs</button>
|
||||
<button type="button" id="ip-ban-filter-banned">Currently Banned</button>
|
||||
<button type="button" id="ip-ban-filter-expired">Expired</button>
|
||||
<button type="button" id="refresh-ip-bans-btn">REFRESH</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IP Bans List -->
|
||||
<div class="input-group">
|
||||
<label>Banned IP Addresses:</label>
|
||||
<div class="config-table-container">
|
||||
<table class="config-table" id="ip-bans-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP Address</th>
|
||||
<th>Status</th>
|
||||
<th>Banned Until</th>
|
||||
<th>Failures</th>
|
||||
<th>Authed Successfully</th>
|
||||
<th>Connection Attempts</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ip-bans-tbody">
|
||||
<tr>
|
||||
<td colspan="7" style="text-align: center;">Click REFRESH to load IP bans</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RELAY EVENTS Section -->
|
||||
<div class="section" id="relayEventsSection" style="display: none;">
|
||||
<div class="section-header">
|
||||
@@ -419,6 +575,10 @@ AUTH RULES MANAGEMENT
|
||||
<option value="event_kinds">Event Kinds Distribution</option>
|
||||
<option value="time_stats">Time-based Statistics</option>
|
||||
</optgroup>
|
||||
<optgroup label="IP Ban Queries">
|
||||
<option value="banned_ips_summary">Banned IPs Summary</option>
|
||||
<option value="banned_ips_list">All Banned IP Addresses</option>
|
||||
</optgroup>
|
||||
<optgroup label="Query History" id="history-group">
|
||||
<!-- Dynamically populated from localStorage -->
|
||||
</optgroup>
|
||||
|
||||
+1050
-92
File diff suppressed because it is too large
Load Diff
+95
-1
@@ -3297,8 +3297,18 @@ var NostrTools = (() => {
|
||||
this.enablePing = opts.enablePing;
|
||||
}
|
||||
async ensureRelay(url, params) {
|
||||
const rawUrl = url;
|
||||
const debugEnabled = typeof window !== "undefined" && !!window.__SIMPLE_POOL_DEBUG__;
|
||||
url = normalizeURL(url);
|
||||
let relay = this.relays.get(url);
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL ensureRelay START", {
|
||||
rawUrl,
|
||||
normalizedUrl: url,
|
||||
hadRelay: !!relay,
|
||||
relayMapKeysBefore: Array.from(this.relays.keys())
|
||||
});
|
||||
}
|
||||
if (!relay) {
|
||||
relay = new AbstractRelay(url, {
|
||||
verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent,
|
||||
@@ -3306,13 +3316,38 @@ var NostrTools = (() => {
|
||||
enablePing: this.enablePing
|
||||
});
|
||||
relay.onclose = () => {
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL ensureRelay onclose", {
|
||||
normalizedUrl: url,
|
||||
relayMapKeysBeforeDelete: Array.from(this.relays.keys())
|
||||
});
|
||||
}
|
||||
this.relays.delete(url);
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL ensureRelay onclose complete", {
|
||||
normalizedUrl: url,
|
||||
relayMapKeysAfterDelete: Array.from(this.relays.keys())
|
||||
});
|
||||
}
|
||||
};
|
||||
if (params?.connectionTimeout)
|
||||
relay.connectionTimeout = params.connectionTimeout;
|
||||
this.relays.set(url, relay);
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL ensureRelay created relay", {
|
||||
normalizedUrl: url,
|
||||
relayMapKeysAfterCreate: Array.from(this.relays.keys())
|
||||
});
|
||||
}
|
||||
}
|
||||
await relay.connect();
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL ensureRelay connected", {
|
||||
normalizedUrl: url,
|
||||
relayConnected: relay.connected,
|
||||
relayMapKeysAfterConnect: Array.from(this.relays.keys())
|
||||
});
|
||||
}
|
||||
return relay;
|
||||
}
|
||||
close(relays) {
|
||||
@@ -3334,16 +3369,26 @@ var NostrTools = (() => {
|
||||
}
|
||||
subscribeMany(relays, filters, params) {
|
||||
params.onauth = params.onauth || params.doauth;
|
||||
const debugEnabled = typeof window !== "undefined" && !!window.__SIMPLE_POOL_DEBUG__;
|
||||
const request = [];
|
||||
const uniqUrls = [];
|
||||
for (let i2 = 0; i2 < relays.length; i2++) {
|
||||
const url = normalizeURL(relays[i2]);
|
||||
if (uniqUrls.indexOf(url) === -1) {
|
||||
uniqUrls.push(url);
|
||||
for (let f2 = 0; f2 < filters.length; f2++) {
|
||||
request.push({ url, filter: filters[f2] });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL subscribeMany", {
|
||||
relaysInput: relays,
|
||||
uniqUrls,
|
||||
filtersCount: filters.length,
|
||||
requestsCount: request.length
|
||||
});
|
||||
}
|
||||
return this.subscribeMap(request, params);
|
||||
}
|
||||
subscribeMap(requests, params) {
|
||||
@@ -3391,14 +3436,31 @@ var NostrTools = (() => {
|
||||
_knownIds.add(id);
|
||||
return have;
|
||||
};
|
||||
const debugEnabled = typeof window !== "undefined" && !!window.__SIMPLE_POOL_DEBUG__;
|
||||
const allOpened = Promise.all(
|
||||
requests.map(async ({ url, filter }, i2) => {
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL subscribeMap request", {
|
||||
index: i2,
|
||||
url,
|
||||
filterKinds: filter?.kinds,
|
||||
hasAuthorFilter: !!filter?.authors,
|
||||
hasPTagFilter: !!filter?.["#p"]
|
||||
});
|
||||
}
|
||||
let relay;
|
||||
try {
|
||||
relay = await this.ensureRelay(url, {
|
||||
connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1e3) : void 0
|
||||
});
|
||||
} catch (err) {
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL subscribeMap ensureRelay FAILED", {
|
||||
index: i2,
|
||||
url,
|
||||
error: err?.message || String(err)
|
||||
});
|
||||
}
|
||||
handleClose(i2, err?.message || String(err));
|
||||
return;
|
||||
}
|
||||
@@ -3480,18 +3542,50 @@ var NostrTools = (() => {
|
||||
return events[0] || null;
|
||||
}
|
||||
publish(relays, event, options) {
|
||||
return relays.map(normalizeURL).map(async (url, i2, arr) => {
|
||||
const debugEnabled = typeof window !== "undefined" && !!window.__SIMPLE_POOL_DEBUG__;
|
||||
const normalizedRelays = relays.map(normalizeURL);
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL publish START", {
|
||||
relaysInput: relays,
|
||||
normalizedRelays,
|
||||
eventKind: event?.kind,
|
||||
eventId: event?.id
|
||||
});
|
||||
}
|
||||
return normalizedRelays.map(async (url, i2, arr) => {
|
||||
if (arr.indexOf(url) !== i2) {
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL publish duplicate URL", { url, index: i2 });
|
||||
}
|
||||
return Promise.reject("duplicate url");
|
||||
}
|
||||
let r = await this.ensureRelay(url);
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL publish ensured relay", {
|
||||
url,
|
||||
relayConnected: r?.connected,
|
||||
relayMapKeys: Array.from(this.relays.keys())
|
||||
});
|
||||
}
|
||||
return r.publish(event).catch(async (err) => {
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL publish ERROR", {
|
||||
url,
|
||||
error: err?.message || String(err)
|
||||
});
|
||||
}
|
||||
if (err instanceof Error && err.message.startsWith("auth-required: ") && options?.onauth) {
|
||||
await r.auth(options.onauth);
|
||||
return r.publish(event);
|
||||
}
|
||||
throw err;
|
||||
}).then((reason) => {
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL publish SUCCESS", {
|
||||
url,
|
||||
reason
|
||||
});
|
||||
}
|
||||
if (this.trackRelays) {
|
||||
let set = this.seenOn.get(event.id);
|
||||
if (!set) {
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
key,value,data_type,description,category,requires_restart,created_at,updated_at
|
||||
|
+9970
File diff suppressed because it is too large
Load Diff
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 &
|
||||
|
||||
+1
-1
Submodule nostr_core_lib updated: a8dc2ed046...98cfd81b01
@@ -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,585 @@
|
||||
# Plan: Ban Idle and Early-Disconnect Connections
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The relay needs to defend against spam connections that:
|
||||
1. Connect via WebSocket and sit idle doing nothing (the original problem)
|
||||
2. Connect and immediately disconnect without subscribing or posting (the new requirement)
|
||||
|
||||
Both patterns indicate bot/scanner behavior that should result in IP bans.
|
||||
|
||||
## Goal
|
||||
|
||||
Implement a system that:
|
||||
- Bans IPs that connect but never send a REQ or EVENT (regardless of how the connection ends)
|
||||
- Works **independent of NIP-42 authentication** (no auth required)
|
||||
- Allows legitimate users to connect and use the relay normally
|
||||
- Uses existing IP ban infrastructure
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Client connects] --> B{IP banned?}
|
||||
B -->|Yes| C[Reject immediately]
|
||||
B -->|No| D[Set idle timeout timer]
|
||||
D --> E{Client sends REQ or EVENT?}
|
||||
E -->|Yes| F[Mark session ACTIVE<br>Cancel idle timer]
|
||||
E -->|No| G{Connection closes?}
|
||||
G --> H{Session was ACTIVE?}
|
||||
H -->|Yes| I[Clean close - no ban]
|
||||
H -->|No| J["ip_ban_record_failure()<br>→ may trigger ban"]
|
||||
F --> K[Normal operation]
|
||||
K --> L[Connection closes]
|
||||
L --> I
|
||||
```
|
||||
|
||||
## Key Insight
|
||||
|
||||
The distinction between "idle timeout" and "early disconnect" is minimal:
|
||||
- **Idle timeout**: Connection closed by server because client never became ACTIVE
|
||||
- **Early disconnect**: Connection closed by client because client never became ACTIVE
|
||||
|
||||
Both result in the same check: `if (!session_was_active) ban_ip()`
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### 1. Add Session Activity Tracking
|
||||
|
||||
**File: `src/websockets.h`**
|
||||
|
||||
Add to `struct per_session_data`:
|
||||
|
||||
```c
|
||||
// Session activity tracking for idle connection banning
|
||||
int session_active; // 1 if client sent REQ or EVENT, 0 otherwise
|
||||
int idle_timeout_sec; // Timeout value for this session (copied from config)
|
||||
```
|
||||
|
||||
### 2. Set Idle Timeout on All Connections
|
||||
|
||||
**File: `src/websockets.c` - `LWS_CALLBACK_ESTABLISHED`**
|
||||
|
||||
Currently (lines 561-572):
|
||||
```c
|
||||
// Only set timeout if auth is required
|
||||
if (pss->nip42_auth_required_events || pss->nip42_auth_required_subscriptions) {
|
||||
int auth_timeout = get_config_int("nip42_auth_timeout_sec", 10);
|
||||
if (auth_timeout > 0) {
|
||||
lws_set_timeout(wsi, PENDING_TIMEOUT_AWAITING_PING, auth_timeout);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Change to:
|
||||
```c
|
||||
// Initialize session activity tracking
|
||||
pss->session_active = 0;
|
||||
pss->idle_timeout_sec = get_config_int("idle_connection_timeout_sec", 30);
|
||||
|
||||
// Set idle timeout for ALL connections (not just auth-required)
|
||||
// This catches bots that connect and do nothing
|
||||
if (pss->idle_timeout_sec > 0) {
|
||||
lws_set_timeout(wsi, PENDING_TIMEOUT_AWAITING_PING, pss->idle_timeout_sec);
|
||||
DEBUG_TRACE("Idle timeout set: %d seconds for connection from %s",
|
||||
pss->idle_timeout_sec, pss->client_ip);
|
||||
}
|
||||
|
||||
// Also set auth timeout if auth is required (separate concern)
|
||||
if (pss->nip42_auth_required_events || pss->nip42_auth_required_subscriptions) {
|
||||
int auth_timeout = get_config_int("nip42_auth_timeout_sec", 10);
|
||||
// Use the shorter of the two timeouts
|
||||
int effective_timeout = (pss->idle_timeout_sec > 0 && pss->idle_timeout_sec < auth_timeout)
|
||||
? pss->idle_timeout_sec
|
||||
: auth_timeout;
|
||||
if (effective_timeout > 0) {
|
||||
lws_set_timeout(wsi, PENDING_TIMEOUT_AWAITING_PING, effective_timeout);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Mark Session as Active on Valid Activity
|
||||
|
||||
**File: `src/websockets.c` - `LWS_CALLBACK_RECEIVE`**
|
||||
|
||||
When processing REQ message (around line 1030):
|
||||
```c
|
||||
// Before handling REQ, mark session as active
|
||||
pthread_mutex_lock(&pss->session_lock);
|
||||
pss->session_active = 1;
|
||||
pthread_mutex_unlock(&pss->session_lock);
|
||||
|
||||
// Cancel idle timeout - this is legitimate usage
|
||||
lws_set_timeout(wsi, NO_PENDING_TIMEOUT, 0);
|
||||
```
|
||||
|
||||
When processing EVENT message (around line 1380):
|
||||
```c
|
||||
// Before handling EVENT, mark session as active
|
||||
pthread_mutex_lock(&pss->session_lock);
|
||||
pss->session_active = 1;
|
||||
pthread_mutex_unlock(&pss->session_lock);
|
||||
|
||||
// Cancel idle timeout - this is legitimate usage
|
||||
lws_set_timeout(wsi, NO_PENDING_TIMEOUT, 0);
|
||||
```
|
||||
|
||||
**Note**: We should also consider AUTH as "activity" since the client is engaging with the protocol:
|
||||
```c
|
||||
// In handle_nip42_auth_signed_event (nip042.c)
|
||||
// After successful auth, also mark session active
|
||||
pthread_mutex_lock(&pss->session_lock);
|
||||
pss->session_active = 1;
|
||||
pthread_mutex_unlock(&pss->session_lock);
|
||||
```
|
||||
|
||||
### 3.5. Separate Rate Limiting for Idle Connections
|
||||
|
||||
Per user feedback, idle/early-disconnect failures have **separate rate limiting** from auth failures. This requires:
|
||||
|
||||
**New Config Keys:**
|
||||
|
||||
| Config Key | Type | Default | Range | Description |
|
||||
|------------|------|---------|-------|-------------|
|
||||
| `idle_ban_threshold` | int | 3 | 1-100 | Idle failures before ban |
|
||||
| `idle_ban_window_sec` | int | 60 | 10-3600 | Window for counting idle failures |
|
||||
| `idle_ban_duration_sec` | int | 300 | 60-86400 | Initial idle ban duration |
|
||||
|
||||
**Add to `ip_ban_entry_t` in `src/ip_ban.c`:**
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
int state;
|
||||
char ip[46];
|
||||
|
||||
// Auth failure tracking (existing)
|
||||
int failure_count;
|
||||
time_t first_failure;
|
||||
time_t banned_until;
|
||||
int ban_count;
|
||||
|
||||
// NEW: Idle failure tracking (separate)
|
||||
int idle_failure_count;
|
||||
time_t idle_first_failure;
|
||||
time_t idle_banned_until;
|
||||
int idle_ban_count;
|
||||
|
||||
// Other existing fields...
|
||||
int has_authed_successfully;
|
||||
time_t last_success_at;
|
||||
int total_connections;
|
||||
int total_failures;
|
||||
int total_successes;
|
||||
time_t first_seen;
|
||||
} ip_ban_entry_t;
|
||||
```
|
||||
|
||||
**New function in `src/ip_ban.c`:**
|
||||
|
||||
```c
|
||||
// Record an idle/early-disconnect failure for an IP
|
||||
void ip_ban_record_idle_failure(const char* ip) {
|
||||
if (!ip || !g_initialized) return;
|
||||
if (!get_config_bool("idle_ban_enabled", 1)) return;
|
||||
|
||||
int threshold = get_config_int("idle_ban_threshold", 3);
|
||||
int window_sec = get_config_int("idle_ban_window_sec", 60);
|
||||
int ban_duration = get_config_int("idle_ban_duration_sec", 300);
|
||||
|
||||
pthread_mutex_lock(&g_ban_mutex);
|
||||
ip_ban_entry_t* entry = get_or_create_entry(ip);
|
||||
if (!entry) {
|
||||
pthread_mutex_unlock(&g_ban_mutex);
|
||||
return;
|
||||
}
|
||||
|
||||
time_t now = time(NULL);
|
||||
|
||||
// Reset window if expired
|
||||
if (entry->idle_first_failure > 0 && (now - entry->idle_first_failure) > window_sec) {
|
||||
entry->idle_failure_count = 0;
|
||||
entry->idle_first_failure = now;
|
||||
}
|
||||
if (entry->idle_first_failure == 0) {
|
||||
entry->idle_first_failure = now;
|
||||
}
|
||||
|
||||
entry->idle_failure_count++;
|
||||
entry->total_failures++;
|
||||
|
||||
DEBUG_TRACE("IP %s idle failure count: %d/%d", ip, entry->idle_failure_count, threshold);
|
||||
|
||||
if (entry->idle_failure_count >= threshold) {
|
||||
int duration = ban_duration;
|
||||
for (int i = 0; i < entry->idle_ban_count && duration < 86400; i++) {
|
||||
duration *= 2;
|
||||
}
|
||||
if (duration > 86400) duration = 86400;
|
||||
|
||||
entry->idle_banned_until = now + duration;
|
||||
entry->idle_ban_count++;
|
||||
entry->idle_failure_count = 0;
|
||||
entry->idle_first_failure = 0;
|
||||
|
||||
DEBUG_WARN("IP %s banned for %d seconds (idle ban #%d) after %d idle failures",
|
||||
ip, duration, entry->idle_ban_count, threshold);
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&g_ban_mutex);
|
||||
}
|
||||
```
|
||||
|
||||
**Update `ip_ban_is_banned()` to check BOTH ban types:**
|
||||
|
||||
```c
|
||||
int ip_ban_is_banned(const char* ip) {
|
||||
if (!ip || !g_initialized) return 0;
|
||||
|
||||
pthread_mutex_lock(&g_ban_mutex);
|
||||
int idx = find_slot(ip);
|
||||
if (idx < 0 || g_ban_table[idx].state == IP_BAN_EMPTY) {
|
||||
pthread_mutex_unlock(&g_ban_mutex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
ip_ban_entry_t* entry = &g_ban_table[idx];
|
||||
time_t now = time(NULL);
|
||||
int banned = 0;
|
||||
|
||||
// Check auth ban (if enabled)
|
||||
if (get_config_bool("auth_fail_ban_enabled", 1) &&
|
||||
entry->banned_until > 0 && now < entry->banned_until) {
|
||||
banned = 1;
|
||||
}
|
||||
|
||||
// Check idle ban (if enabled)
|
||||
if (get_config_bool("idle_ban_enabled", 1) &&
|
||||
entry->idle_banned_until > 0 && now < entry->idle_banned_until) {
|
||||
banned = 1;
|
||||
}
|
||||
|
||||
// Clear expired bans
|
||||
if (!banned) {
|
||||
if (entry->banned_until > 0 && now >= entry->banned_until) {
|
||||
entry->banned_until = 0;
|
||||
entry->failure_count = 0;
|
||||
entry->first_failure = 0;
|
||||
}
|
||||
if (entry->idle_banned_until > 0 && now >= entry->idle_banned_until) {
|
||||
entry->idle_banned_until = 0;
|
||||
entry->idle_failure_count = 0;
|
||||
entry->idle_first_failure = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&g_ban_mutex);
|
||||
return banned;
|
||||
}
|
||||
```
|
||||
|
||||
**Update persistence:**
|
||||
- `ip_ban_load_from_db()`: Load idle_* fields from new columns
|
||||
- `ip_ban_save_to_db()`: Save idle_* fields to new columns
|
||||
- Add migration SQL to create new columns if they don't exist
|
||||
|
||||
**Add to `src/ip_ban.h`:**
|
||||
|
||||
```c
|
||||
// Record an idle/early-disconnect failure for an IP
|
||||
void ip_ban_record_idle_failure(const char* ip);
|
||||
```
|
||||
|
||||
### 4. Record Failure on Inactive Connection Close
|
||||
|
||||
**File: `src/websockets.c` - `LWS_CALLBACK_CLOSED`**
|
||||
|
||||
Currently (lines 2121-2128):
|
||||
```c
|
||||
// Record auth failure if connection closed while unauthenticated and auth was required
|
||||
if (!pss->authenticated &&
|
||||
(pss->nip42_auth_required_events || pss->nip42_auth_required_subscriptions) &&
|
||||
pss->auth_challenge_sent &&
|
||||
strlen(pss->client_ip) > 0) {
|
||||
ip_ban_record_failure(pss->client_ip);
|
||||
}
|
||||
```
|
||||
|
||||
Change to:
|
||||
```c
|
||||
// Record failure if connection closed without ever becoming active
|
||||
// This catches:
|
||||
// 1. Idle connections that timed out (never sent REQ/EVENT/AUTH)
|
||||
// 2. Early disconnects (client closed before sending REQ/EVENT/AUTH)
|
||||
if (!pss->session_active && strlen(pss->client_ip) > 0) {
|
||||
// Use separate idle failure recording (has its own threshold/duration)
|
||||
ip_ban_record_idle_failure(pss->client_ip);
|
||||
DEBUG_LOG("Recording idle/early-disconnect failure for IP %s (connected %ld seconds)",
|
||||
pss->client_ip,
|
||||
time(NULL) - pss->connection_established);
|
||||
}
|
||||
// Legacy: record auth failure if auth was required but not completed
|
||||
else if (!pss->authenticated &&
|
||||
(pss->nip42_auth_required_events || pss->nip42_auth_required_subscriptions) &&
|
||||
pss->auth_challenge_sent &&
|
||||
strlen(pss->client_ip) > 0) {
|
||||
ip_ban_record_failure(pss->client_ip);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Add Configuration Keys
|
||||
|
||||
**File: `src/config.c` - Add validation for new keys**
|
||||
|
||||
In the config validation section, add:
|
||||
|
||||
```c
|
||||
// Idle connection ban settings
|
||||
if (strcmp(key, "idle_connection_timeout_sec") == 0) {
|
||||
if (!is_valid_positive_integer(value)) {
|
||||
snprintf(error_msg, error_size, "invalid idle_connection_timeout_sec '%s' (must be positive integer)", value);
|
||||
return -1;
|
||||
}
|
||||
int timeout = atoi(value);
|
||||
if (timeout < 5 || timeout > 300) {
|
||||
snprintf(error_msg, error_size, "idle_connection_timeout_sec must be between 5 and 300 seconds");
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (strcmp(key, "idle_ban_enabled") == 0) {
|
||||
if (!is_valid_boolean(value)) {
|
||||
snprintf(error_msg, error_size, "invalid boolean value '%s' for idle_ban_enabled", value);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (strcmp(key, "idle_ban_threshold") == 0) {
|
||||
if (!is_valid_positive_integer(value)) {
|
||||
snprintf(error_msg, error_size, "invalid idle_ban_threshold '%s' (must be positive integer)", value);
|
||||
return -1;
|
||||
}
|
||||
int threshold = atoi(value);
|
||||
if (threshold < 1 || threshold > 100) {
|
||||
snprintf(error_msg, error_size, "idle_ban_threshold must be between 1 and 100");
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (strcmp(key, "idle_ban_window_sec") == 0) {
|
||||
if (!is_valid_positive_integer(value)) {
|
||||
snprintf(error_msg, error_size, "invalid idle_ban_window_sec '%s' (must be positive integer)", value);
|
||||
return -1;
|
||||
}
|
||||
int window = atoi(value);
|
||||
if (window < 10 || window > 3600) {
|
||||
snprintf(error_msg, error_size, "idle_ban_window_sec must be between 10 and 3600");
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (strcmp(key, "idle_ban_duration_sec") == 0) {
|
||||
if (!is_valid_positive_integer(value)) {
|
||||
snprintf(error_msg, error_size, "invalid idle_ban_duration_sec '%s' (must be positive integer)", value);
|
||||
return -1;
|
||||
}
|
||||
int duration = atoi(value);
|
||||
if (duration < 60 || duration > 86400) {
|
||||
snprintf(error_msg, error_size, "idle_ban_duration_sec must be between 60 and 86400");
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
Also update the config introspection/documentation functions to describe these keys.
|
||||
|
||||
### 6. Add API Documentation
|
||||
|
||||
**File: `src/api.c` - Add to config metadata**
|
||||
|
||||
```c
|
||||
{"idle_connection_timeout_sec", "int", 5, 300},
|
||||
{"idle_ban_enabled", "bool", 0, 1},
|
||||
{"idle_ban_threshold", "int", 1, 100},
|
||||
{"idle_ban_window_sec", "int", 10, 3600},
|
||||
{"idle_ban_duration_sec", "int", 60, 86400},
|
||||
```
|
||||
|
||||
Add descriptions in the config query handler:
|
||||
```c
|
||||
} else if (strcmp(key, "idle_connection_timeout_sec") == 0) {
|
||||
description = "Seconds before an idle connection (no REQ/EVENT) is closed and IP flagged. 0 to disable.";
|
||||
} else if (strcmp(key, "idle_ban_enabled") == 0) {
|
||||
description = "Whether to ban IPs that repeatedly connect without sending REQ or EVENT.";
|
||||
} else if (strcmp(key, "idle_ban_threshold") == 0) {
|
||||
description = "Number of idle/early-disconnect failures before banning an IP.";
|
||||
} else if (strcmp(key, "idle_ban_window_sec") == 0) {
|
||||
description = "Time window in seconds for counting idle failures.";
|
||||
} else if (strcmp(key, "idle_ban_duration_sec") == 0) {
|
||||
description = "Initial ban duration in seconds for idle failures (doubles each time).";
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Update ip_ban Persistence and Cleanup
|
||||
|
||||
**File: `src/ip_ban.c` - Update `ip_ban_load_from_db()`**
|
||||
|
||||
Add migration SQL to create new columns if they don't exist, then load them:
|
||||
```sql
|
||||
ALTER TABLE ip_bans ADD COLUMN idle_failure_count INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE ip_bans ADD COLUMN idle_ban_count INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE ip_bans ADD COLUMN idle_banned_until INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE ip_bans ADD COLUMN idle_first_failure INTEGER NOT NULL DEFAULT 0;
|
||||
```
|
||||
|
||||
**File: `src/ip_ban.c` - Update `ip_ban_save_to_db()`**
|
||||
|
||||
Add the idle_* fields to the INSERT/REPLACE statement.
|
||||
|
||||
**File: `src/ip_ban.c` - Update `ip_ban_cleanup()`**
|
||||
|
||||
Add cleanup logic for idle ban entries (same pattern as auth ban cleanup).
|
||||
|
||||
**File: `src/ip_ban.c` - Update `ip_ban_log_stats()`**
|
||||
|
||||
Add idle ban count to the periodic log summary:
|
||||
```c
|
||||
DEBUG_WARN("IP BAN SUMMARY: %d auth-banned, %d idle-banned, %d tracked, %d trusted",
|
||||
auth_banned_count, idle_banned_count, tracked_count, trusted_count);
|
||||
```
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### Idle Connection Settings
|
||||
|
||||
| Config Key | Type | Default | Range | Description |
|
||||
|------------|------|---------|-------|-------------|
|
||||
| `idle_connection_timeout_sec` | int | 30 | 5-300 | Seconds to wait for REQ/EVENT before closing connection. 0 = disable idle timeout. |
|
||||
| `idle_ban_enabled` | bool | true | true/false | Whether to ban IPs with repeated idle/early-disconnect failures. |
|
||||
| `idle_ban_threshold` | int | 3 | 1-100 | Idle failures before ban. |
|
||||
| `idle_ban_window_sec` | int | 60 | 10-3600 | Window for counting idle failures. |
|
||||
| `idle_ban_duration_sec` | int | 300 | 60-86400 | Initial idle ban duration. |
|
||||
|
||||
### Auth Failure Settings (Existing)
|
||||
|
||||
| Config Key | Type | Default | Range | Description |
|
||||
|------------|------|---------|-------|-------------|
|
||||
| `auth_fail_ban_enabled` | bool | true | true/false | Whether to ban IPs with failed auth. |
|
||||
| `auth_fail_ban_threshold` | int | 3 | 1-100 | Auth failures before ban. |
|
||||
| `auth_fail_window_sec` | int | 60 | 10-3600 | Window for counting auth failures. |
|
||||
| `auth_fail_ban_duration_sec` | int | 300 | 60-86400 | Initial auth ban duration. |
|
||||
|
||||
## Behavior Matrix
|
||||
|
||||
| Scenario | idle_timeout_sec | idle_ban_enabled | Result |
|
||||
|----------|------------------|------------------|--------|
|
||||
| Connect → do nothing → timeout | >0 | true | Connection closed, IP failure recorded, may be banned |
|
||||
| Connect → do nothing → timeout | >0 | false | Connection closed, no ban |
|
||||
| Connect → immediate disconnect | any | true | IP failure recorded, may be banned |
|
||||
| Connect → immediate disconnect | any | false | No ban |
|
||||
| Connect → send REQ | any | any | Session active, no ban |
|
||||
| Connect → send EVENT | any | any | Session active, no ban |
|
||||
| Connect → send AUTH (NIP-42) | any | any | Session active, no ban |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Idle timeout test**: Connect via wscat, wait 30 seconds, verify disconnect and ban table entry
|
||||
2. **Early disconnect test**: Connect via wscat, immediately Ctrl-C, verify ban table entry
|
||||
3. **Active session test**: Connect, send REQ, disconnect immediately, verify NO ban
|
||||
4. **Config disable test**: Set `idle_ban_enabled=false`, repeat test 1, verify no ban
|
||||
5. **Timeout config test**: Set `idle_connection_timeout_sec=5`, verify faster disconnect
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Change Type |
|
||||
|------|-------------|
|
||||
| `src/websockets.h` | Add `session_active` and `idle_timeout_sec` fields to `per_session_data` |
|
||||
| `src/websockets.c` ~565 | Set idle timeout on ALL connections in `LWS_CALLBACK_ESTABLISHED` |
|
||||
| `src/websockets.c` ~1030 | Mark `session_active=1` and cancel idle timer on REQ |
|
||||
| `src/websockets.c` ~1380 | Mark `session_active=1` and cancel idle timer on EVENT |
|
||||
| `src/websockets.c` ~2121 | Call `ip_ban_record_idle_failure()` for inactive sessions in `LWS_CALLBACK_CLOSED` |
|
||||
| `src/nip042.c` ~130 | Mark `session_active=1` on successful AUTH |
|
||||
| `src/ip_ban.h` | Add `ip_ban_record_idle_failure()` declaration |
|
||||
| `src/ip_ban.c` | Add idle_* fields to `ip_ban_entry_t`, new `ip_ban_record_idle_failure()` function, update `ip_ban_is_banned()` to check both ban types, update persistence and cleanup |
|
||||
| `src/config.c` ~987+ | Add validation for 5 new idle_* config keys |
|
||||
| `src/api.c` ~664 | Add config metadata entries for idle_* keys |
|
||||
|
||||
## Deployment on Existing Server
|
||||
|
||||
### Config Keys: No Manual SQL Required
|
||||
|
||||
All config keys use [`get_config_int()`](src/config.c:300) and [`get_config_bool()`](src/config.c:313) which return **hardcoded defaults** when a key doesn't exist in the config table. The new code will work immediately with these defaults:
|
||||
|
||||
- `idle_connection_timeout_sec` → defaults to 30
|
||||
- `idle_ban_enabled` → defaults to true
|
||||
- `idle_ban_threshold` → defaults to 3
|
||||
- `idle_ban_window_sec` → defaults to 60
|
||||
- `idle_ban_duration_sec` → defaults to 300
|
||||
|
||||
If you want to **override** any defaults, you can insert them into the config table. From the same directory as the `.db` file:
|
||||
|
||||
```bash
|
||||
# Find your database file
|
||||
DB_FILE=$(ls *.db | head -1)
|
||||
|
||||
# Optional: Insert custom values (only if you want non-default settings)
|
||||
sqlite3 "$DB_FILE" "INSERT OR REPLACE INTO config (key, value) VALUES ('idle_connection_timeout_sec', '30');"
|
||||
sqlite3 "$DB_FILE" "INSERT OR REPLACE INTO config (key, value) VALUES ('idle_ban_enabled', 'true');"
|
||||
sqlite3 "$DB_FILE" "INSERT OR REPLACE INTO config (key, value) VALUES ('idle_ban_threshold', '3');"
|
||||
sqlite3 "$DB_FILE" "INSERT OR REPLACE INTO config (key, value) VALUES ('idle_ban_window_sec', '60');"
|
||||
sqlite3 "$DB_FILE" "INSERT OR REPLACE INTO config (key, value) VALUES ('idle_ban_duration_sec', '300');"
|
||||
```
|
||||
|
||||
Or change them via the admin API/web UI after deployment.
|
||||
|
||||
### ip_bans Table: Automatic Migration
|
||||
|
||||
The `ip_bans` table needs 4 new columns for idle tracking. The updated [`ip_ban_load_from_db()`](src/ip_ban.c:93) will run `ALTER TABLE` statements automatically on startup. These are safe because SQLite's `ALTER TABLE ADD COLUMN` is a no-op if the column already exists (we'll use error-tolerant execution).
|
||||
|
||||
If you prefer to run the migration manually before deploying:
|
||||
|
||||
```bash
|
||||
DB_FILE=$(ls *.db | head -1)
|
||||
|
||||
sqlite3 "$DB_FILE" <<'SQL'
|
||||
ALTER TABLE ip_bans ADD COLUMN idle_failure_count INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE ip_bans ADD COLUMN idle_ban_count INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE ip_bans ADD COLUMN idle_banned_until INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE ip_bans ADD COLUMN idle_first_failure INTEGER NOT NULL DEFAULT 0;
|
||||
SQL
|
||||
```
|
||||
|
||||
**Note:** SQLite will error on `ALTER TABLE ADD COLUMN` if the column already exists, but the code will handle this gracefully (ignore the error). Running it manually is optional — the relay will do it on startup.
|
||||
|
||||
### Deployment Steps
|
||||
|
||||
1. Build and deploy with `deploy_lt.sh` as usual
|
||||
2. The relay starts, `ip_ban_load_from_db()` auto-migrates the `ip_bans` table
|
||||
3. Config keys use defaults immediately — no manual SQL needed
|
||||
4. Optionally tune settings via admin API or direct SQL
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- Default `idle_connection_timeout_sec=30` provides immediate protection
|
||||
- Default `idle_ban_enabled=true` maintains current spam protection behavior
|
||||
- Setting `idle_connection_timeout_sec=0` restores old behavior (no idle timeout)
|
||||
- Setting `idle_ban_enabled=false` allows connections without banning (for debugging)
|
||||
- Existing auth-based banning continues to work independently with its own thresholds
|
||||
- Database migration adds new columns with defaults — existing ban data preserved
|
||||
|
||||
## Summary
|
||||
|
||||
This plan implements a unified "session activity" tracking system with **separate rate limiting** for idle vs auth failures:
|
||||
|
||||
1. **Catches idle connections** via `lws_set_timeout()` on ALL connections (not just auth-required)
|
||||
2. **Catches early disconnects** via the same `!session_active` check on close
|
||||
3. **Respects legitimate users** who actually use the relay (REQ/EVENT/AUTH marks session active)
|
||||
4. **Separate idle ban tracking** — idle failures have their own threshold, window, and duration independent of auth failures
|
||||
5. **Extends existing ban infrastructure** — adds idle_* fields to `ip_ban_entry_t`, unified `ip_ban_is_banned()` checks both ban types
|
||||
6. **Fully configurable** — 5 new config keys for idle banning, all tunable via admin events
|
||||
|
||||
The key insight is that there's no meaningful difference between "idle timeout" and "early disconnect" — both indicate a client that connected but never actually used the relay. The same `!session_active` check handles both cases, and the separate rate limiting ensures idle bots don't interfere with auth failure tracking for legitimate clients who fail NIP-42.
|
||||
@@ -0,0 +1,207 @@
|
||||
# C-Relay Memory Leak Investigation & Fix Plan
|
||||
|
||||
## Problem Statement
|
||||
|
||||
c-relay reached **1.56 GB** of its 1.5 GB `MemoryHigh` cgroup limit after only **1 hour 37 minutes** of runtime. The kernel is actively throttling the process with swap pressure (1.8M swap used). This strongly indicates one or more memory leaks causing unbounded growth.
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
After thorough code review, I identified **three categories** of memory issues:
|
||||
|
||||
1. **Confirmed `get_config_value()` leaks** — the most pervasive issue
|
||||
2. **Potential structural leaks** in subscription/connection lifecycle
|
||||
3. **Stack pressure** from large stack-allocated arrays
|
||||
|
||||
---
|
||||
|
||||
## Category 1: `get_config_value()` Leaks (HIGH SEVERITY — Most Likely Root Cause)
|
||||
|
||||
### The Core Problem
|
||||
|
||||
[`get_config_value()`](src/config.c:293) calls [`get_config_value_from_table()`](src/config.c:1690) which returns `strdup(value)` — a **heap-allocated string that the caller must free**. Many call sites treat the return value as a borrowed pointer and never free it.
|
||||
|
||||
**Every unfree'd call leaks ~64-256 bytes per invocation.** On a busy relay processing hundreds of events/second, this accumulates to GB-scale leaks within hours.
|
||||
|
||||
### Confirmed Leak Sites
|
||||
|
||||
#### `websockets.c` — Called on EVERY incoming event
|
||||
|
||||
| Line | Code | Frequency | Severity |
|
||||
|------|------|-----------|----------|
|
||||
| [1527](src/websockets.c:1527) | `get_config_value("relay_pubkey")` — auth bypass check for kind 14 DMs | Every kind-14 event | **HIGH** |
|
||||
| [1549](src/websockets.c:1549) | `get_config_value("admin_pubkey")` — auth bypass check for kind 23456 | Every kind-23456 event | **MEDIUM** |
|
||||
| [2704](src/websockets.c:2704) | `get_config_value("relay_pubkey")` — DM stats command processing | Every DM to relay | **MEDIUM** |
|
||||
| [2742](src/websockets.c:2742) | `get_config_value("admin_pubkey")` — DM admin check | Every DM to relay | **MEDIUM** |
|
||||
|
||||
#### `main.c` — Called on EVERY admin event check
|
||||
|
||||
| Line | Code | Frequency | Severity |
|
||||
|------|------|-----------|----------|
|
||||
| [1718](src/main.c:1718) | `get_config_value("relay_pubkey")` — inside a loop iterating tags | Every admin event, per tag | **CRITICAL** |
|
||||
| [1742](src/main.c:1742) | `get_config_value("admin_pubkey")` — admin authorization | Every admin event | **HIGH** |
|
||||
|
||||
#### `config.c` — Called on EVERY event kind check
|
||||
|
||||
| Line | Code | Frequency | Severity |
|
||||
|------|------|-----------|----------|
|
||||
| [377](src/config.c:377) | `get_config_value("nip42_auth_required_kinds")` — NIP-42 kind check | Every incoming event | **CRITICAL** |
|
||||
|
||||
#### `ip_ban.c` — Called on EVERY ban check
|
||||
|
||||
| Line | Code | Frequency | Severity |
|
||||
|------|------|-----------|----------|
|
||||
| [255](src/ip_ban.c:255) | `get_config_value("idle_ban_whitelist")` — whitelist check | Every connection check | **HIGH** |
|
||||
|
||||
#### `nip042.c` — Called on every auth verification
|
||||
|
||||
| Line | Code | Frequency | Severity |
|
||||
|------|------|-----------|----------|
|
||||
| [99](src/nip042.c:99) | `get_config_value("relay_url")` — relay URL for auth verification | Every NIP-42 auth | **MEDIUM** |
|
||||
|
||||
#### `request_validator.c` — Called on every auth rules check
|
||||
|
||||
| Line | Code | Frequency | Severity |
|
||||
|------|------|-----------|----------|
|
||||
| [208](src/request_validator.c:208) | `get_config_value("auth_enabled")` — properly freed | N/A | OK |
|
||||
| [216](src/request_validator.c:216) | `get_config_value("auth_rules_enabled")` — properly freed | N/A | OK |
|
||||
| [304](src/request_validator.c:304) | `get_config_value("admin_pubkey")` — NOT freed | Every event validation | **HIGH** |
|
||||
| [320](src/request_validator.c:320) | `get_config_value("nip42_auth_enabled")` — NOT freed | Every event validation | **HIGH** |
|
||||
|
||||
### Files That DO Free Correctly (for reference)
|
||||
|
||||
- [`nip011.c`](src/nip011.c:127) — Properly frees all `get_config_value()` results
|
||||
- [`nip013.c`](src/nip013.c:43) — Properly frees `pow_mode`
|
||||
- [`request_validator.c`](src/request_validator.c:191) — Properly frees `nip42_timeout` and `nip42_tolerance`
|
||||
|
||||
### Estimated Impact
|
||||
|
||||
On a relay processing ~100 events/second:
|
||||
- `is_nip42_auth_required_for_kind()` leaks ~100-200 bytes × 100/sec = **~10-20 KB/sec**
|
||||
- `ip_is_whitelisted()` leaks ~100 bytes × connections/sec
|
||||
- `is_authorized_admin_event()` leaks inside tag loop — potentially **multiple leaks per event**
|
||||
- Combined: **~50-100 KB/sec → ~180-360 MB/hour** — consistent with the observed 1.56 GB in 97 minutes
|
||||
|
||||
---
|
||||
|
||||
## Category 2: Structural Lifecycle Leaks (MEDIUM SEVERITY)
|
||||
|
||||
### 2a. `LWS_CALLBACK_CLOSED` — Inactive Subscription Skip
|
||||
|
||||
In [`websockets.c:2339`](src/websockets.c:2339), the cleanup handler only collects subscription IDs where `sub->active` is true:
|
||||
|
||||
```c
|
||||
if (sub->active) { // Only process active subscriptions
|
||||
temp_sub_id_t* temp = malloc(sizeof(temp_sub_id_t));
|
||||
```
|
||||
|
||||
If a subscription was marked inactive by another thread between the time it was added and the connection close, it will be **skipped** — never removed from the global manager, never freed. The subscription object, its filters, and all cJSON objects within leak permanently.
|
||||
|
||||
### 2b. `connection_list_remove` Potential
|
||||
|
||||
The `connection_list_remove(wsi)` call at [`websockets.c:2252`](src/websockets.c:2252) happens before pss cleanup. Need to verify the connection list implementation doesn't hold references that prevent cleanup.
|
||||
|
||||
### 2c. `ip_connection_info_t` Leak on Disconnect
|
||||
|
||||
The per-IP connection tracking in [`subscriptions.h:79`](src/subscriptions.h:79) creates `ip_connection_info_t` nodes via `calloc`. These are only removed by `remove_ip_connection()` — need to verify this is called on every disconnect path.
|
||||
|
||||
---
|
||||
|
||||
## Category 3: Stack Pressure (LOW SEVERITY but notable)
|
||||
|
||||
### 3a. `candidates_to_check` Stack Array
|
||||
|
||||
In [`subscriptions.c:810`](src/subscriptions.c:810):
|
||||
|
||||
```c
|
||||
subscription_t* candidates_to_check[MAX_TOTAL_SUBSCRIPTIONS]; // 5000 × 8 bytes = 40 KB
|
||||
```
|
||||
|
||||
This allocates **40 KB on the stack** for every `broadcast_event_to_subscriptions()` call. While not a heap leak, it contributes to high memory pressure under concurrent load.
|
||||
|
||||
### 3b. `added_kinds` Bitmap
|
||||
|
||||
In [`subscriptions.c:68`](src/subscriptions.c:68):
|
||||
|
||||
```c
|
||||
unsigned char added_kinds[8192] = {0}; // 8 KB on stack
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fix Implementation Plan
|
||||
|
||||
### Phase 1: Fix `get_config_value()` Leaks (Highest Impact)
|
||||
|
||||
**Strategy A — Preferred: Add a config cache layer**
|
||||
|
||||
Instead of fixing 20+ call sites, add a **cached config system** that reads values once and caches them in memory, invalidating on config change events. This eliminates both the leak AND the repeated SQLite queries per event.
|
||||
|
||||
```
|
||||
Config Cache Architecture:
|
||||
- Static hash table of key-value pairs
|
||||
- Populated at startup and on config change events
|
||||
- get_config_value_cached() returns const pointer to cached value (no allocation)
|
||||
- Existing get_config_value() kept for dynamic/rare lookups
|
||||
```
|
||||
|
||||
**Strategy B — Quick fix: Free at every call site**
|
||||
|
||||
Add `free((char*)result)` after every `get_config_value()` call that doesn't already free. This is more error-prone but faster to implement.
|
||||
|
||||
**Recommendation: Implement Strategy A for hot-path values (relay_pubkey, admin_pubkey, auth settings), Strategy B for cold-path values.**
|
||||
|
||||
### Phase 2: Fix Structural Leaks
|
||||
|
||||
1. **Fix inactive subscription skip in CLOSED handler** — Remove the `if (sub->active)` guard, or add a second pass that also collects inactive subscriptions for cleanup
|
||||
2. **Verify `remove_ip_connection()` is called on all disconnect paths**
|
||||
3. **Add defensive cleanup** — periodic sweep of orphaned subscriptions
|
||||
|
||||
### Phase 3: Add Memory Monitoring
|
||||
|
||||
1. **Add a `/stats` endpoint** that reports:
|
||||
- Current RSS/VSZ from `/proc/self/status`
|
||||
- Active subscription count
|
||||
- Message queue depth across all sessions
|
||||
- Config cache hit/miss ratio
|
||||
2. **Add periodic memory logging** to the service loop
|
||||
3. **Consider integrating jemalloc** for better allocation tracking in production
|
||||
|
||||
### Phase 4: Reduce Stack Pressure
|
||||
|
||||
1. Move `candidates_to_check` to heap allocation with `malloc`/`free`
|
||||
2. Consider reducing `MAX_TOTAL_SUBSCRIPTIONS` or using a dynamic array
|
||||
|
||||
---
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
1. **Build with AddressSanitizer** (`-fsanitize=address`) for development testing
|
||||
2. **Run under Valgrind** with `--leak-check=full` for comprehensive leak detection
|
||||
3. **Monitor RSS over time** after fixes — should plateau rather than grow linearly
|
||||
4. **Load test** with `tests/load_tests.sh` while monitoring memory
|
||||
|
||||
---
|
||||
|
||||
## Immediate Mitigation
|
||||
|
||||
While fixes are being implemented:
|
||||
1. **Raise `MemoryHigh`** to 2 GB in the systemd unit to prevent throttling
|
||||
2. **Add a cron job** to restart the service every 12-24 hours
|
||||
3. **Monitor with**: `watch -n 5 'cat /proc/$(pgrep c_relay)/status | grep -E "VmRSS|VmSwap"'`
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority Order
|
||||
|
||||
| Priority | Task | Impact |
|
||||
|----------|------|--------|
|
||||
| P0 | Fix `is_nip42_auth_required_for_kind()` leak in config.c:377 | Called on every event |
|
||||
| P0 | Fix `ip_is_whitelisted()` leak in ip_ban.c:255 | Called on every connection check |
|
||||
| P0 | Fix `is_authorized_admin_event()` leaks in main.c:1718,1742 | Called per admin event, leaks in loop |
|
||||
| P1 | Fix websockets.c:1527,1549 auth bypass leaks | Called on every event with auth |
|
||||
| P1 | Fix request_validator.c:304,320 leaks | Called on every event validation |
|
||||
| P1 | Fix nip042.c:99 relay_url leak | Called on every NIP-42 auth |
|
||||
| P2 | Implement config cache for hot-path values | Eliminates leak class entirely |
|
||||
| P2 | Fix inactive subscription cleanup in CLOSED handler | Prevents slow subscription leak |
|
||||
| P3 | Add memory monitoring endpoint | Ongoing visibility |
|
||||
| P3 | Move large stack arrays to heap | Reduces stack pressure |
|
||||
@@ -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 ... │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -13,6 +13,7 @@ extern void log_query_execution(const char* query_type, const char* sub_id,
|
||||
#include <pthread.h>
|
||||
#include <libwebsockets.h>
|
||||
#include <cjson/cJSON.h>
|
||||
int get_active_connection_count(void);
|
||||
#include <sqlite3.h>
|
||||
#include <time.h>
|
||||
#include <sys/stat.h>
|
||||
@@ -499,10 +500,10 @@ int generate_monitoring_event_for_type(const char* d_tag_value, cJSON* (*query_f
|
||||
// Use the library function to create and sign the event
|
||||
cJSON* signed_event = nostr_create_and_sign_event(
|
||||
24567, // kind (ephemeral)
|
||||
cJSON_GetStringValue(cJSON_GetObjectItem(monitoring_event, "content")), // content
|
||||
cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(monitoring_event, "content")), // content
|
||||
tags, // tags
|
||||
relay_privkey, // private key
|
||||
(time_t)cJSON_GetNumberValue(cJSON_GetObjectItem(monitoring_event, "created_at")) // timestamp
|
||||
(time_t)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(monitoring_event, "created_at")) // timestamp
|
||||
);
|
||||
|
||||
if (!signed_event) {
|
||||
@@ -671,6 +672,13 @@ static const config_definition_t known_configs[] = {
|
||||
{"nip42_time_tolerance", "int", 60, 3600},
|
||||
{"nip70_protected_events_enabled", "bool", 0, 1},
|
||||
|
||||
// Idle Connection Ban Settings
|
||||
{"idle_connection_timeout_sec", "int", 0, 300},
|
||||
{"idle_ban_enabled", "bool", 0, 1},
|
||||
{"idle_ban_threshold", "int", 1, 100},
|
||||
{"idle_ban_window_sec", "int", 10, 3600},
|
||||
{"idle_ban_duration_sec", "int", 60, 86400},
|
||||
|
||||
// Server Core Settings
|
||||
{"relay_port", "int", 1, 65535},
|
||||
{"max_connections", "int", 1, 10000},
|
||||
@@ -922,10 +930,10 @@ int send_admin_response(const char* sender_pubkey, const char* response_content,
|
||||
// Use the library function to create and sign the event
|
||||
cJSON* signed_event = nostr_create_and_sign_event(
|
||||
23457, // kind
|
||||
cJSON_GetStringValue(cJSON_GetObjectItem(response_event, "content")), // content
|
||||
cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(response_event, "content")), // content
|
||||
tags, // tags
|
||||
relay_privkey, // private key
|
||||
(time_t)cJSON_GetNumberValue(cJSON_GetObjectItem(response_event, "created_at")) // timestamp
|
||||
(time_t)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(response_event, "created_at")) // timestamp
|
||||
);
|
||||
|
||||
if (!signed_event) {
|
||||
@@ -1169,7 +1177,7 @@ int handle_sql_query_unified(cJSON* event, const char* query, char* error_messag
|
||||
}
|
||||
|
||||
// Get request event ID for response correlation
|
||||
cJSON* request_id_obj = cJSON_GetObjectItem(event, "id");
|
||||
cJSON* request_id_obj = cJSON_GetObjectItemCaseSensitive(event, "id");
|
||||
if (!request_id_obj || !cJSON_IsString(request_id_obj)) {
|
||||
snprintf(error_message, error_size, "Missing request event ID");
|
||||
return -1;
|
||||
@@ -1188,7 +1196,7 @@ int handle_sql_query_unified(cJSON* event, const char* query, char* error_messag
|
||||
}
|
||||
|
||||
// Get sender pubkey for response
|
||||
cJSON* sender_pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
|
||||
cJSON* sender_pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
|
||||
if (!sender_pubkey_obj || !cJSON_IsString(sender_pubkey_obj)) {
|
||||
free(result_json);
|
||||
snprintf(error_message, error_size, "Missing sender pubkey");
|
||||
@@ -1284,13 +1292,16 @@ cJSON* query_cpu_metrics(void) {
|
||||
pid_t pid = getpid();
|
||||
cJSON_AddNumberToObject(cpu_stats, "process_id", (double)pid);
|
||||
|
||||
// Get memory usage from /proc/self/status
|
||||
// Get active WebSocket connection count
|
||||
cJSON_AddNumberToObject(cpu_stats, "active_connections", (double)get_active_connection_count());
|
||||
|
||||
// Get memory usage from /proc/self/status and calculate MEM%
|
||||
unsigned long rss_kb = 0;
|
||||
FILE* mem_stat = fopen("/proc/self/status", "r");
|
||||
if (mem_stat) {
|
||||
char line[256];
|
||||
while (fgets(line, sizeof(line), mem_stat)) {
|
||||
if (strncmp(line, "VmRSS:", 6) == 0) {
|
||||
unsigned long rss_kb;
|
||||
if (sscanf(line, "VmRSS: %lu kB", &rss_kb) == 1) {
|
||||
double rss_mb = rss_kb / 1024.0;
|
||||
cJSON_AddNumberToObject(cpu_stats, "memory_usage_mb", rss_mb);
|
||||
@@ -1301,6 +1312,25 @@ cJSON* query_cpu_metrics(void) {
|
||||
fclose(mem_stat);
|
||||
}
|
||||
|
||||
// Get total system memory from /proc/meminfo for MEM%
|
||||
if (rss_kb > 0) {
|
||||
FILE* meminfo = fopen("/proc/meminfo", "r");
|
||||
if (meminfo) {
|
||||
char line[256];
|
||||
while (fgets(line, sizeof(line), meminfo)) {
|
||||
if (strncmp(line, "MemTotal:", 9) == 0) {
|
||||
unsigned long total_kb;
|
||||
if (sscanf(line, "MemTotal: %lu kB", &total_kb) == 1 && total_kb > 0) {
|
||||
double mem_pct = (rss_kb * 100.0) / total_kb;
|
||||
cJSON_AddNumberToObject(cpu_stats, "memory_percent", mem_pct);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose(meminfo);
|
||||
}
|
||||
}
|
||||
|
||||
return cpu_stats;
|
||||
}
|
||||
|
||||
@@ -1491,7 +1521,7 @@ int send_nip17_response(const char* sender_pubkey, const char* response_content,
|
||||
}
|
||||
|
||||
// Fix the p tag in the gift wrap - library function may use wrong pubkey
|
||||
cJSON* gift_wrap_tags = cJSON_GetObjectItem(gift_wraps[0], "tags");
|
||||
cJSON* gift_wrap_tags = cJSON_GetObjectItemCaseSensitive(gift_wraps[0], "tags");
|
||||
if (gift_wrap_tags && cJSON_IsArray(gift_wrap_tags)) {
|
||||
// Find and replace the p tag with the correct user pubkey
|
||||
cJSON* tag = NULL;
|
||||
@@ -1627,13 +1657,13 @@ char* generate_stats_text(void) {
|
||||
|
||||
if (stats_obj) {
|
||||
// Extract basic metrics
|
||||
cJSON* total_events = cJSON_GetObjectItem(stats_obj, "total_events");
|
||||
cJSON* db_size = cJSON_GetObjectItem(stats_obj, "database_size_bytes");
|
||||
cJSON* oldest_event = cJSON_GetObjectItem(stats_obj, "database_created_at");
|
||||
cJSON* newest_event = cJSON_GetObjectItem(stats_obj, "latest_event_at");
|
||||
cJSON* time_stats = cJSON_GetObjectItem(stats_obj, "time_stats");
|
||||
cJSON* event_kinds = cJSON_GetObjectItem(stats_obj, "event_kinds");
|
||||
// cJSON* top_pubkeys = cJSON_GetObjectItem(stats_obj, "top_pubkeys");
|
||||
cJSON* total_events = cJSON_GetObjectItemCaseSensitive(stats_obj, "total_events");
|
||||
cJSON* db_size = cJSON_GetObjectItemCaseSensitive(stats_obj, "database_size_bytes");
|
||||
cJSON* oldest_event = cJSON_GetObjectItemCaseSensitive(stats_obj, "database_created_at");
|
||||
cJSON* newest_event = cJSON_GetObjectItemCaseSensitive(stats_obj, "latest_event_at");
|
||||
cJSON* time_stats = cJSON_GetObjectItemCaseSensitive(stats_obj, "time_stats");
|
||||
cJSON* event_kinds = cJSON_GetObjectItemCaseSensitive(stats_obj, "event_kinds");
|
||||
// cJSON* top_pubkeys = cJSON_GetObjectItemCaseSensitive(stats_obj, "top_pubkeys");
|
||||
|
||||
long long total = total_events ? (long long)cJSON_GetNumberValue(total_events) : 0;
|
||||
long long db_bytes = db_size ? (long long)cJSON_GetNumberValue(db_size) : 0;
|
||||
@@ -1661,9 +1691,9 @@ char* generate_stats_text(void) {
|
||||
// Extract time-based stats
|
||||
long long last_24h = 0, last_7d = 0, last_30d = 0;
|
||||
if (time_stats) {
|
||||
cJSON* h24 = cJSON_GetObjectItem(time_stats, "last_24h");
|
||||
cJSON* d7 = cJSON_GetObjectItem(time_stats, "last_7d");
|
||||
cJSON* d30 = cJSON_GetObjectItem(time_stats, "last_30d");
|
||||
cJSON* h24 = cJSON_GetObjectItemCaseSensitive(time_stats, "last_24h");
|
||||
cJSON* d7 = cJSON_GetObjectItemCaseSensitive(time_stats, "last_7d");
|
||||
cJSON* d30 = cJSON_GetObjectItemCaseSensitive(time_stats, "last_30d");
|
||||
last_24h = h24 ? (long long)cJSON_GetNumberValue(h24) : 0;
|
||||
last_7d = d7 ? (long long)cJSON_GetNumberValue(d7) : 0;
|
||||
last_30d = d30 ? (long long)cJSON_GetNumberValue(d30) : 0;
|
||||
@@ -1695,9 +1725,9 @@ char* generate_stats_text(void) {
|
||||
if (event_kinds && cJSON_IsArray(event_kinds)) {
|
||||
cJSON* kind_item = NULL;
|
||||
cJSON_ArrayForEach(kind_item, event_kinds) {
|
||||
cJSON* kind = cJSON_GetObjectItem(kind_item, "kind");
|
||||
cJSON* count = cJSON_GetObjectItem(kind_item, "count");
|
||||
cJSON* percentage = cJSON_GetObjectItem(kind_item, "percentage");
|
||||
cJSON* kind = cJSON_GetObjectItemCaseSensitive(kind_item, "kind");
|
||||
cJSON* count = cJSON_GetObjectItemCaseSensitive(kind_item, "count");
|
||||
cJSON* percentage = cJSON_GetObjectItemCaseSensitive(kind_item, "percentage");
|
||||
|
||||
if (kind && count && percentage) {
|
||||
// Format event kind (right-justified, minimum 5 chars wide with underscores)
|
||||
@@ -1767,9 +1797,9 @@ char* generate_stats_text(void) {
|
||||
// int rank = 1;
|
||||
// cJSON* pubkey_item = NULL;
|
||||
// cJSON_ArrayForEach(pubkey_item, top_pubkeys) {
|
||||
// cJSON* pubkey = cJSON_GetObjectItem(pubkey_item, "pubkey");
|
||||
// cJSON* event_count = cJSON_GetObjectItem(pubkey_item, "event_count");
|
||||
// cJSON* percentage = cJSON_GetObjectItem(pubkey_item, "percentage");
|
||||
// cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(pubkey_item, "pubkey");
|
||||
// cJSON* event_count = cJSON_GetObjectItemCaseSensitive(pubkey_item, "event_count");
|
||||
// cJSON* percentage = cJSON_GetObjectItemCaseSensitive(pubkey_item, "percentage");
|
||||
|
||||
// if (pubkey && event_count && percentage) {
|
||||
// const char* pubkey_str = cJSON_GetStringValue(pubkey);
|
||||
@@ -2222,6 +2252,16 @@ char* generate_config_change_confirmation(const char* key, const char* old_value
|
||||
description = "This sets the maximum subscriptions per client.";
|
||||
} else if (strcmp(key, "pow_min_difficulty") == 0) {
|
||||
description = "This sets the minimum proof-of-work difficulty required.";
|
||||
} else if (strcmp(key, "idle_connection_timeout_sec") == 0) {
|
||||
description = "Seconds before an idle connection (no REQ/EVENT) is closed and IP flagged. 0 to disable.";
|
||||
} else if (strcmp(key, "idle_ban_enabled") == 0) {
|
||||
description = "Whether to ban IPs that repeatedly connect without sending REQ or EVENT.";
|
||||
} else if (strcmp(key, "idle_ban_threshold") == 0) {
|
||||
description = "Number of idle/early-disconnect failures before banning an IP.";
|
||||
} else if (strcmp(key, "idle_ban_window_sec") == 0) {
|
||||
description = "Time window in seconds for counting idle failures.";
|
||||
} else if (strcmp(key, "idle_ban_duration_sec") == 0) {
|
||||
description = "Initial ban duration in seconds for idle failures (doubles each time).";
|
||||
} else if (strstr(key, "relay_") == key) {
|
||||
description = "This changes relay metadata information.";
|
||||
}
|
||||
@@ -2457,7 +2497,7 @@ int handle_create_relay_event_command(cJSON* event, int kind, cJSON* event_data,
|
||||
}
|
||||
|
||||
// Get request event ID for response correlation
|
||||
cJSON* request_id_obj = cJSON_GetObjectItem(event, "id");
|
||||
cJSON* request_id_obj = cJSON_GetObjectItemCaseSensitive(event, "id");
|
||||
if (!request_id_obj || !cJSON_IsString(request_id_obj)) {
|
||||
snprintf(error_message, error_size, "Missing request event ID");
|
||||
return -1;
|
||||
@@ -2465,7 +2505,7 @@ int handle_create_relay_event_command(cJSON* event, int kind, cJSON* event_data,
|
||||
const char* request_id = cJSON_GetStringValue(request_id_obj);
|
||||
|
||||
// Get sender pubkey for response
|
||||
cJSON* sender_pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
|
||||
cJSON* sender_pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
|
||||
if (!sender_pubkey_obj || !cJSON_IsString(sender_pubkey_obj)) {
|
||||
snprintf(error_message, error_size, "Missing sender pubkey");
|
||||
return -1;
|
||||
@@ -2614,7 +2654,7 @@ cJSON* create_relay_dm_list_event(cJSON* dm_relays) {
|
||||
}
|
||||
|
||||
// Extract relays array
|
||||
cJSON* relays_array = cJSON_GetObjectItem(dm_relays, "relays");
|
||||
cJSON* relays_array = cJSON_GetObjectItemCaseSensitive(dm_relays, "relays");
|
||||
if (relays_array && cJSON_IsArray(relays_array)) {
|
||||
cJSON* relay_item = NULL;
|
||||
cJSON_ArrayForEach(relay_item, relays_array) {
|
||||
@@ -2686,14 +2726,14 @@ cJSON* create_relay_list_event(cJSON* relays) {
|
||||
}
|
||||
|
||||
// Extract relays array
|
||||
cJSON* relays_array = cJSON_GetObjectItem(relays, "relays");
|
||||
cJSON* relays_array = cJSON_GetObjectItemCaseSensitive(relays, "relays");
|
||||
if (relays_array && cJSON_IsArray(relays_array)) {
|
||||
cJSON* relay_item = NULL;
|
||||
cJSON_ArrayForEach(relay_item, relays_array) {
|
||||
if (cJSON_IsObject(relay_item)) {
|
||||
cJSON* url = cJSON_GetObjectItem(relay_item, "url");
|
||||
cJSON* read = cJSON_GetObjectItem(relay_item, "read");
|
||||
cJSON* write = cJSON_GetObjectItem(relay_item, "write");
|
||||
cJSON* url = cJSON_GetObjectItemCaseSensitive(relay_item, "url");
|
||||
cJSON* read = cJSON_GetObjectItemCaseSensitive(relay_item, "read");
|
||||
cJSON* write = cJSON_GetObjectItemCaseSensitive(relay_item, "write");
|
||||
|
||||
if (url && cJSON_IsString(url)) {
|
||||
const char* relay_url = cJSON_GetStringValue(url);
|
||||
@@ -2752,7 +2792,7 @@ int handle_monitoring_command(cJSON* event, const char* command, char* error_mes
|
||||
}
|
||||
|
||||
// Get request event ID for response correlation
|
||||
cJSON* request_id_obj = cJSON_GetObjectItem(event, "id");
|
||||
cJSON* request_id_obj = cJSON_GetObjectItemCaseSensitive(event, "id");
|
||||
if (!request_id_obj || !cJSON_IsString(request_id_obj)) {
|
||||
snprintf(error_message, error_size, "Missing request event ID");
|
||||
return -1;
|
||||
@@ -2760,7 +2800,7 @@ int handle_monitoring_command(cJSON* event, const char* command, char* error_mes
|
||||
const char* request_id = cJSON_GetStringValue(request_id_obj);
|
||||
|
||||
// Get sender pubkey for response
|
||||
cJSON* sender_pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
|
||||
cJSON* sender_pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
|
||||
if (!sender_pubkey_obj || !cJSON_IsString(sender_pubkey_obj)) {
|
||||
snprintf(error_message, error_size, "Missing sender pubkey");
|
||||
return -1;
|
||||
|
||||
+602
-57
@@ -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;
|
||||
@@ -378,22 +378,25 @@ int is_nip42_auth_required_for_kind(int event_kind) {
|
||||
if (!kinds_str) {
|
||||
return 0; // No authentication required if setting is missing
|
||||
}
|
||||
|
||||
|
||||
// Parse the kinds list
|
||||
int required_kinds[100]; // Support up to 100 different kinds
|
||||
int count = parse_auth_required_kinds(kinds_str, required_kinds, 100);
|
||||
|
||||
|
||||
if (count < 0) {
|
||||
free((char*)kinds_str);
|
||||
return 0; // Parse error, default to no auth required
|
||||
}
|
||||
|
||||
|
||||
// Check if event_kind is in the list
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (required_kinds[i] == event_kind) {
|
||||
free((char*)kinds_str);
|
||||
return 1; // Authentication required for this kind
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
free((char*)kinds_str);
|
||||
return 0; // Authentication not required for this kind
|
||||
}
|
||||
|
||||
@@ -665,8 +668,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 +954,131 @@ static int validate_config_field(const char* key, const char* value, char* error
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Debug level (0-5)
|
||||
if (strcmp(key, "debug_level") == 0) {
|
||||
if (!is_valid_positive_integer(value) && strcmp(value, "0") != 0) {
|
||||
snprintf(error_msg, error_size, "invalid debug_level '%s' (must be 0-5)", value);
|
||||
return -1;
|
||||
}
|
||||
int val = atoi(value);
|
||||
if (val < 0 || val > 5) {
|
||||
snprintf(error_msg, error_size, "debug_level '%s' out of range (0-5)", value);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// IP auth failure ban settings
|
||||
if (strcmp(key, "auth_fail_ban_enabled") == 0) {
|
||||
if (!is_valid_boolean(value)) {
|
||||
snprintf(error_msg, error_size, "invalid boolean value '%s' for auth_fail_ban_enabled", value);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(key, "auth_fail_ban_threshold") == 0 ||
|
||||
strcmp(key, "auth_fail_window_sec") == 0 ||
|
||||
strcmp(key, "auth_fail_ban_duration_sec") == 0) {
|
||||
if (!is_valid_positive_integer(value)) {
|
||||
snprintf(error_msg, error_size, "invalid value '%s' for %s (must be positive integer)", value, key);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Idle connection ban settings
|
||||
if (strcmp(key, "idle_connection_timeout_sec") == 0) {
|
||||
if (!is_valid_positive_integer(value) && strcmp(value, "0") != 0) {
|
||||
snprintf(error_msg, error_size, "invalid idle_connection_timeout_sec '%s' (must be non-negative integer)", value);
|
||||
return -1;
|
||||
}
|
||||
int timeout = atoi(value);
|
||||
if (timeout < 0 || timeout > 300) {
|
||||
snprintf(error_msg, error_size, "idle_connection_timeout_sec must be between 0 and 300 seconds");
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (strcmp(key, "idle_ban_enabled") == 0) {
|
||||
if (!is_valid_boolean(value)) {
|
||||
snprintf(error_msg, error_size, "invalid boolean value '%s' for idle_ban_enabled", value);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (strcmp(key, "idle_ban_threshold") == 0) {
|
||||
if (!is_valid_positive_integer(value)) {
|
||||
snprintf(error_msg, error_size, "invalid idle_ban_threshold '%s' (must be positive integer)", value);
|
||||
return -1;
|
||||
}
|
||||
int threshold = atoi(value);
|
||||
if (threshold < 1 || threshold > 100) {
|
||||
snprintf(error_msg, error_size, "idle_ban_threshold must be between 1 and 100");
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (strcmp(key, "idle_ban_window_sec") == 0) {
|
||||
if (!is_valid_positive_integer(value)) {
|
||||
snprintf(error_msg, error_size, "invalid idle_ban_window_sec '%s' (must be positive integer)", value);
|
||||
return -1;
|
||||
}
|
||||
int window = atoi(value);
|
||||
if (window < 10 || window > 3600) {
|
||||
snprintf(error_msg, error_size, "idle_ban_window_sec must be between 10 and 3600");
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (strcmp(key, "idle_ban_duration_sec") == 0) {
|
||||
if (!is_valid_positive_integer(value)) {
|
||||
snprintf(error_msg, error_size, "invalid idle_ban_duration_sec '%s' (must be positive integer)", value);
|
||||
return -1;
|
||||
}
|
||||
int duration = atoi(value);
|
||||
if (duration < 60 || duration > 86400) {
|
||||
snprintf(error_msg, error_size, "idle_ban_duration_sec must be between 60 and 86400");
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// NIP-42 auth timeout
|
||||
if (strcmp(key, "nip42_auth_timeout_sec") == 0) {
|
||||
if (!is_valid_positive_integer(value) && strcmp(value, "0") != 0) {
|
||||
snprintf(error_msg, error_size, "invalid nip42_auth_timeout_sec '%s' (must be non-negative integer)", value);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// SQLite performance tuning
|
||||
if (strcmp(key, "sqlite_mmap_size") == 0) {
|
||||
if (!is_valid_positive_integer(value) && strcmp(value, "0") != 0) {
|
||||
snprintf(error_msg, error_size, "invalid sqlite_mmap_size '%s' (must be non-negative integer bytes)", value);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(key, "sqlite_cache_size_kb") == 0) {
|
||||
if (!is_valid_positive_integer(value)) {
|
||||
snprintf(error_msg, error_size, "invalid sqlite_cache_size_kb '%s' (must be positive integer KB)", value);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Boolean fields
|
||||
if (strcmp(key, "auth_enabled") == 0 ||
|
||||
strcmp(key, "nip40_expiration_enabled") == 0 ||
|
||||
strcmp(key, "nip40_expiration_strict") == 0 ||
|
||||
strcmp(key, "nip40_expiration_filter") == 0) {
|
||||
strcmp(key, "nip40_expiration_filter") == 0 ||
|
||||
strcmp(key, "nip17_admin_enabled") == 0) {
|
||||
if (!is_valid_boolean(value)) {
|
||||
snprintf(error_msg, error_size, "invalid boolean value '%s' for %s", value, key);
|
||||
return -1;
|
||||
@@ -1256,7 +1378,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 +1442,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 +1517,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 +1840,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 +2062,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 +2087,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 +2177,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 +2254,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 +2552,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 +2570,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 +2680,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 +2794,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 +2888,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 +2913,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 +2935,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);
|
||||
@@ -2651,7 +3066,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) {
|
||||
@@ -2839,7 +3254,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) {
|
||||
@@ -2954,7 +3369,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) {
|
||||
@@ -3025,6 +3440,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();
|
||||
@@ -3036,7 +3465,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) {
|
||||
@@ -3100,7 +3529,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) {
|
||||
@@ -3177,7 +3606,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) {
|
||||
@@ -3238,7 +3667,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) {
|
||||
@@ -3270,7 +3699,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) {
|
||||
@@ -3292,6 +3721,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;
|
||||
@@ -3302,7 +3843,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;
|
||||
@@ -3385,7 +3926,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) {
|
||||
@@ -3515,7 +4056,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) {
|
||||
@@ -3583,7 +4124,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;
|
||||
@@ -3664,10 +4205,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)) {
|
||||
@@ -3864,7 +4405,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) {
|
||||
@@ -3892,7 +4433,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) {
|
||||
@@ -3921,7 +4462,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) {
|
||||
@@ -4081,7 +4622,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 ||
|
||||
@@ -4089,7 +4632,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";
|
||||
}
|
||||
|
||||
@@ -4324,7 +4868,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;
|
||||
@@ -4374,7 +4918,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";
|
||||
}
|
||||
|
||||
@@ -4472,13 +5017,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;
|
||||
@@ -4674,7 +5219,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,52 @@ static const struct {
|
||||
// IP-based rate limiting or access control (which would require firewall protection anyway)
|
||||
{"trust_proxy_headers", "true"},
|
||||
|
||||
// Debug Level (0=none, 1=errors, 2=warnings, 3=info, 4=debug, 5=trace)
|
||||
// Can be changed at runtime without restart via config_set admin command
|
||||
{"debug_level", "3"},
|
||||
|
||||
// IP Auth Failure Ban Settings
|
||||
// Ban IPs that repeatedly fail NIP-42 authentication
|
||||
{"auth_fail_ban_enabled", "true"},
|
||||
{"auth_fail_ban_threshold", "3"}, // failures before ban
|
||||
{"auth_fail_window_sec", "60"}, // window to count failures in
|
||||
{"auth_fail_ban_duration_sec", "300"}, // initial ban duration (doubles each time, max 24h)
|
||||
|
||||
// NIP-42 Authentication Timeout
|
||||
// Seconds after connection before unauthenticated clients are disconnected (0 = disabled)
|
||||
// Prevents unauthenticated connections from accumulating under heavy load
|
||||
{"nip42_auth_timeout_sec", "10"},
|
||||
|
||||
// Idle Connection Ban Settings
|
||||
// Ban IPs that connect but never send REQ or EVENT (idle or early disconnect)
|
||||
{"idle_connection_timeout_sec", "0"}, // Seconds before idle connection is closed (0 = disabled)
|
||||
{"idle_ban_enabled", "false"}, // Whether to ban IPs with idle failures
|
||||
{"idle_ban_threshold", "1"}, // Idle failures before ban (1 = ban on first offense)
|
||||
{"idle_ban_window_sec", "30"}, // Window to count idle failures in
|
||||
{"idle_ban_duration_sec", "300"}, // Initial ban duration (doubles each time, max 24h)
|
||||
|
||||
// SQLite Performance Tuning
|
||||
// mmap_size: bytes of database file to memory-map (0 = disabled, 268435456 = 256MB recommended)
|
||||
// Eliminates pread64 syscall overhead for database reads — significant CPU savings under load
|
||||
{"sqlite_mmap_size", "268435456"},
|
||||
// cache_size_kb: SQLite page cache size in KB (negative = KB, positive = pages of 4KB each)
|
||||
// Default 2000KB is too small for a busy relay; 65536KB (64MB) keeps hot data in memory
|
||||
{"sqlite_cache_size_kb", "65536"},
|
||||
|
||||
// NIP-59 Gift Wrap Timestamp Configuration
|
||||
{"nip59_timestamp_max_delay_sec", "0"},
|
||||
|
||||
// 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
+584
@@ -0,0 +1,584 @@
|
||||
#define _GNU_SOURCE
|
||||
#include "ip_ban.h"
|
||||
#include "debug.h"
|
||||
#include "config.h"
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <pthread.h>
|
||||
|
||||
// ============================================================
|
||||
// IP Auth Failure Ban System
|
||||
//
|
||||
// Fixed-size open-addressing hash table. No malloc after init.
|
||||
// Thread-safe via a single mutex (low contention — only called
|
||||
// at connection open/close, not in the hot event path).
|
||||
//
|
||||
// State is persisted to the ip_bans SQLite table every 5 minutes
|
||||
// and loaded at startup so bans survive relay restarts.
|
||||
// ============================================================
|
||||
|
||||
#define IP_BAN_EMPTY 0
|
||||
#define IP_BAN_ACTIVE 1
|
||||
|
||||
typedef struct {
|
||||
int state; // IP_BAN_EMPTY or IP_BAN_ACTIVE
|
||||
char ip[46]; // IPv4 or IPv6 string
|
||||
|
||||
// Auth failure tracking (existing)
|
||||
int failure_count; // failures in current window
|
||||
time_t first_failure; // start of current failure window
|
||||
time_t banned_until; // 0 = not banned
|
||||
int ban_count; // escalation level (for exponential backoff)
|
||||
|
||||
// NEW: Idle failure tracking (separate)
|
||||
int idle_failure_count;
|
||||
time_t idle_first_failure;
|
||||
time_t idle_banned_until;
|
||||
int idle_ban_count;
|
||||
|
||||
// Other existing fields
|
||||
int has_authed_successfully; // 1 if this IP has ever authenticated
|
||||
time_t last_success_at; // timestamp of last successful auth
|
||||
int total_connections; // lifetime connection count
|
||||
int total_failures; // lifetime auth failure count
|
||||
int total_successes; // lifetime successful auth count
|
||||
time_t first_seen; // when this IP was first seen
|
||||
} ip_ban_entry_t;
|
||||
|
||||
static ip_ban_entry_t g_ban_table[IP_BAN_TABLE_SIZE];
|
||||
static pthread_mutex_t g_ban_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
static int g_initialized = 0;
|
||||
|
||||
// Simple FNV-1a hash for IP strings
|
||||
static unsigned int ip_hash(const char* ip) {
|
||||
unsigned int hash = 2166136261u;
|
||||
while (*ip) {
|
||||
hash ^= (unsigned char)*ip++;
|
||||
hash *= 16777619u;
|
||||
}
|
||||
return hash % IP_BAN_TABLE_SIZE;
|
||||
}
|
||||
|
||||
// Find slot for IP (open addressing with linear probing)
|
||||
static int find_slot(const char* ip) {
|
||||
unsigned int start = ip_hash(ip);
|
||||
for (unsigned int i = 0; i < IP_BAN_TABLE_SIZE; i++) {
|
||||
unsigned int idx = (start + i) % IP_BAN_TABLE_SIZE;
|
||||
if (g_ban_table[idx].state == IP_BAN_EMPTY) {
|
||||
return (int)idx;
|
||||
}
|
||||
if (strcmp(g_ban_table[idx].ip, ip) == 0) {
|
||||
return (int)idx;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Get or create an entry for an IP. Returns NULL if table is full.
|
||||
// Caller must hold g_ban_mutex.
|
||||
static ip_ban_entry_t* get_or_create_entry(const char* ip) {
|
||||
int idx = find_slot(ip);
|
||||
if (idx < 0) {
|
||||
DEBUG_WARN("IP ban table full, cannot track %s", ip);
|
||||
return NULL;
|
||||
}
|
||||
ip_ban_entry_t* entry = &g_ban_table[idx];
|
||||
if (entry->state == IP_BAN_EMPTY) {
|
||||
entry->state = IP_BAN_ACTIVE;
|
||||
strncpy(entry->ip, ip, sizeof(entry->ip) - 1);
|
||||
entry->ip[sizeof(entry->ip) - 1] = '\0';
|
||||
entry->first_seen = time(NULL);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
void ip_ban_init(void) {
|
||||
pthread_mutex_lock(&g_ban_mutex);
|
||||
memset(g_ban_table, 0, sizeof(g_ban_table));
|
||||
g_initialized = 1;
|
||||
pthread_mutex_unlock(&g_ban_mutex);
|
||||
DEBUG_LOG("IP ban table initialized (%d slots)", IP_BAN_TABLE_SIZE);
|
||||
}
|
||||
|
||||
void ip_ban_load_from_db(sqlite3* db) {
|
||||
if (!db || !g_initialized) return;
|
||||
|
||||
// Create table if it doesn't exist (handles existing databases)
|
||||
const char* create_sql =
|
||||
"CREATE TABLE IF NOT EXISTS ip_bans ("
|
||||
" ip TEXT PRIMARY KEY,"
|
||||
" failure_count INTEGER NOT NULL DEFAULT 0,"
|
||||
" ban_count INTEGER NOT NULL DEFAULT 0,"
|
||||
" banned_until INTEGER NOT NULL DEFAULT 0,"
|
||||
" first_failure INTEGER NOT NULL DEFAULT 0,"
|
||||
" has_authed_successfully INTEGER NOT NULL DEFAULT 0,"
|
||||
" last_success_at INTEGER NOT NULL DEFAULT 0,"
|
||||
" total_connections INTEGER NOT NULL DEFAULT 0,"
|
||||
" total_failures INTEGER NOT NULL DEFAULT 0,"
|
||||
" total_successes INTEGER NOT NULL DEFAULT 0,"
|
||||
" first_seen INTEGER NOT NULL DEFAULT 0,"
|
||||
" updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))"
|
||||
");";
|
||||
char* err = NULL;
|
||||
if (sqlite3_exec(db, create_sql, NULL, NULL, &err) != SQLITE_OK) {
|
||||
DEBUG_ERROR("Failed to create ip_bans table: %s", err ? err : "unknown");
|
||||
if (err) sqlite3_free(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Migration: Add idle_* columns if they don't exist (ignore errors if already exists)
|
||||
sqlite3_exec(db, "ALTER TABLE ip_bans ADD COLUMN idle_failure_count INTEGER NOT NULL DEFAULT 0", NULL, NULL, NULL);
|
||||
sqlite3_exec(db, "ALTER TABLE ip_bans ADD COLUMN idle_ban_count INTEGER NOT NULL DEFAULT 0", NULL, NULL, NULL);
|
||||
sqlite3_exec(db, "ALTER TABLE ip_bans ADD COLUMN idle_banned_until INTEGER NOT NULL DEFAULT 0", NULL, NULL, NULL);
|
||||
sqlite3_exec(db, "ALTER TABLE ip_bans ADD COLUMN idle_first_failure INTEGER NOT NULL DEFAULT 0", NULL, NULL, NULL);
|
||||
|
||||
const char* sql =
|
||||
"SELECT ip, failure_count, ban_count, banned_until, first_failure,"
|
||||
" has_authed_successfully, last_success_at, total_connections,"
|
||||
" total_failures, total_successes, first_seen,"
|
||||
" idle_failure_count, idle_ban_count, idle_banned_until, idle_first_failure"
|
||||
" FROM ip_bans";
|
||||
|
||||
sqlite3_stmt* stmt;
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) {
|
||||
DEBUG_ERROR("Failed to prepare ip_bans load query");
|
||||
return;
|
||||
}
|
||||
|
||||
int loaded = 0;
|
||||
pthread_mutex_lock(&g_ban_mutex);
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char* ip = (const char*)sqlite3_column_text(stmt, 0);
|
||||
if (!ip) continue;
|
||||
|
||||
int idx = find_slot(ip);
|
||||
if (idx < 0) continue;
|
||||
|
||||
ip_ban_entry_t* entry = &g_ban_table[idx];
|
||||
entry->state = IP_BAN_ACTIVE;
|
||||
strncpy(entry->ip, ip, sizeof(entry->ip) - 1);
|
||||
entry->ip[sizeof(entry->ip) - 1] = '\0';
|
||||
entry->failure_count = sqlite3_column_int(stmt, 1);
|
||||
entry->ban_count = sqlite3_column_int(stmt, 2);
|
||||
entry->banned_until = (time_t)sqlite3_column_int64(stmt, 3);
|
||||
entry->first_failure = (time_t)sqlite3_column_int64(stmt, 4);
|
||||
entry->has_authed_successfully = sqlite3_column_int(stmt, 5);
|
||||
entry->last_success_at = (time_t)sqlite3_column_int64(stmt, 6);
|
||||
entry->total_connections = sqlite3_column_int(stmt, 7);
|
||||
entry->total_failures = sqlite3_column_int(stmt, 8);
|
||||
entry->total_successes = sqlite3_column_int(stmt, 9);
|
||||
entry->first_seen = (time_t)sqlite3_column_int64(stmt, 10);
|
||||
// Load idle tracking fields (default to 0 if columns don't exist yet)
|
||||
entry->idle_failure_count = sqlite3_column_int(stmt, 11);
|
||||
entry->idle_ban_count = sqlite3_column_int(stmt, 12);
|
||||
entry->idle_banned_until = (time_t)sqlite3_column_int64(stmt, 13);
|
||||
entry->idle_first_failure = (time_t)sqlite3_column_int64(stmt, 14);
|
||||
loaded++;
|
||||
}
|
||||
pthread_mutex_unlock(&g_ban_mutex);
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
// Count how many are still actively banned
|
||||
time_t now = time(NULL);
|
||||
int still_banned = 0;
|
||||
pthread_mutex_lock(&g_ban_mutex);
|
||||
for (int i = 0; i < IP_BAN_TABLE_SIZE; i++) {
|
||||
if (g_ban_table[i].state == IP_BAN_ACTIVE &&
|
||||
g_ban_table[i].banned_until > now) {
|
||||
still_banned++;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&g_ban_mutex);
|
||||
|
||||
DEBUG_WARN("IP ban table loaded: %d IPs restored (%d still banned)", loaded, still_banned);
|
||||
}
|
||||
|
||||
void ip_ban_save_to_db(sqlite3* db) {
|
||||
if (!db || !g_initialized) return;
|
||||
|
||||
const char* upsert_sql =
|
||||
"INSERT OR REPLACE INTO ip_bans"
|
||||
" (ip, failure_count, ban_count, banned_until, first_failure,"
|
||||
" has_authed_successfully, last_success_at, total_connections,"
|
||||
" total_failures, total_successes, first_seen, updated_at,"
|
||||
" idle_failure_count, idle_ban_count, idle_banned_until, idle_first_failure)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), ?, ?, ?, ?)";
|
||||
|
||||
sqlite3_stmt* stmt;
|
||||
if (sqlite3_prepare_v2(db, upsert_sql, -1, &stmt, NULL) != SQLITE_OK) {
|
||||
DEBUG_ERROR("Failed to prepare ip_bans save query");
|
||||
return;
|
||||
}
|
||||
|
||||
sqlite3_exec(db, "BEGIN TRANSACTION", NULL, NULL, NULL);
|
||||
|
||||
int saved = 0;
|
||||
pthread_mutex_lock(&g_ban_mutex);
|
||||
for (int i = 0; i < IP_BAN_TABLE_SIZE; i++) {
|
||||
ip_ban_entry_t* entry = &g_ban_table[i];
|
||||
if (entry->state != IP_BAN_ACTIVE) continue;
|
||||
|
||||
sqlite3_reset(stmt);
|
||||
sqlite3_bind_text(stmt, 1, entry->ip, -1, SQLITE_STATIC);
|
||||
sqlite3_bind_int(stmt, 2, entry->failure_count);
|
||||
sqlite3_bind_int(stmt, 3, entry->ban_count);
|
||||
sqlite3_bind_int64(stmt, 4, (sqlite3_int64)entry->banned_until);
|
||||
sqlite3_bind_int64(stmt, 5, (sqlite3_int64)entry->first_failure);
|
||||
sqlite3_bind_int(stmt, 6, entry->has_authed_successfully);
|
||||
sqlite3_bind_int64(stmt, 7, (sqlite3_int64)entry->last_success_at);
|
||||
sqlite3_bind_int(stmt, 8, entry->total_connections);
|
||||
sqlite3_bind_int(stmt, 9, entry->total_failures);
|
||||
sqlite3_bind_int(stmt, 10, entry->total_successes);
|
||||
sqlite3_bind_int64(stmt, 11, (sqlite3_int64)entry->first_seen);
|
||||
// Idle tracking fields
|
||||
sqlite3_bind_int(stmt, 12, entry->idle_failure_count);
|
||||
sqlite3_bind_int(stmt, 13, entry->idle_ban_count);
|
||||
sqlite3_bind_int64(stmt, 14, (sqlite3_int64)entry->idle_banned_until);
|
||||
sqlite3_bind_int64(stmt, 15, (sqlite3_int64)entry->idle_first_failure);
|
||||
|
||||
if (sqlite3_step(stmt) != SQLITE_DONE) {
|
||||
DEBUG_WARN("Failed to save ip_ban entry for %s", entry->ip);
|
||||
} else {
|
||||
saved++;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&g_ban_mutex);
|
||||
|
||||
sqlite3_finalize(stmt);
|
||||
sqlite3_exec(db, "COMMIT", NULL, NULL, NULL);
|
||||
|
||||
DEBUG_TRACE("IP ban table saved: %d entries written to DB", saved);
|
||||
}
|
||||
|
||||
// Check if an IP is in the idle_ban_whitelist config (comma-separated list)
|
||||
static int ip_is_whitelisted(const char* ip) {
|
||||
const char* whitelist = get_config_value("idle_ban_whitelist");
|
||||
if (!whitelist || whitelist[0] == '\0') {
|
||||
if (whitelist) free((char*)whitelist);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Make a mutable copy to tokenize
|
||||
char buf[1024];
|
||||
strncpy(buf, whitelist, sizeof(buf) - 1);
|
||||
buf[sizeof(buf) - 1] = '\0';
|
||||
|
||||
int is_match = 0;
|
||||
char* token = strtok(buf, ",");
|
||||
while (token) {
|
||||
// Trim leading/trailing spaces
|
||||
while (*token == ' ') token++;
|
||||
char* end = token + strlen(token) - 1;
|
||||
while (end > token && *end == ' ') { *end = '\0'; end--; }
|
||||
if (strcmp(token, ip) == 0) {
|
||||
is_match = 1;
|
||||
break;
|
||||
}
|
||||
token = strtok(NULL, ",");
|
||||
}
|
||||
|
||||
free((char*)whitelist);
|
||||
return is_match;
|
||||
}
|
||||
|
||||
int ip_ban_is_banned(const char* ip) {
|
||||
if (!ip || !g_initialized) return 0;
|
||||
|
||||
// Whitelisted IPs are never banned
|
||||
if (ip_is_whitelisted(ip)) return 0;
|
||||
|
||||
pthread_mutex_lock(&g_ban_mutex);
|
||||
int idx = find_slot(ip);
|
||||
if (idx < 0 || g_ban_table[idx].state == IP_BAN_EMPTY) {
|
||||
pthread_mutex_unlock(&g_ban_mutex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
ip_ban_entry_t* entry = &g_ban_table[idx];
|
||||
time_t now = time(NULL);
|
||||
int banned = 0;
|
||||
|
||||
// Check auth ban (if enabled)
|
||||
if (get_config_bool("auth_fail_ban_enabled", 1) &&
|
||||
entry->banned_until > 0 && now < entry->banned_until) {
|
||||
banned = 1;
|
||||
}
|
||||
|
||||
// Check idle ban (if enabled)
|
||||
if (get_config_bool("idle_ban_enabled", 1) &&
|
||||
entry->idle_banned_until > 0 && now < entry->idle_banned_until) {
|
||||
banned = 1;
|
||||
}
|
||||
|
||||
// Clear expired bans
|
||||
if (!banned) {
|
||||
if (entry->banned_until > 0 && now >= entry->banned_until) {
|
||||
entry->banned_until = 0;
|
||||
entry->failure_count = 0;
|
||||
entry->first_failure = 0;
|
||||
}
|
||||
if (entry->idle_banned_until > 0 && now >= entry->idle_banned_until) {
|
||||
entry->idle_banned_until = 0;
|
||||
entry->idle_failure_count = 0;
|
||||
entry->idle_first_failure = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&g_ban_mutex);
|
||||
return banned;
|
||||
}
|
||||
|
||||
void ip_ban_record_connection(const char* ip) {
|
||||
if (!ip || !g_initialized) return;
|
||||
|
||||
pthread_mutex_lock(&g_ban_mutex);
|
||||
ip_ban_entry_t* entry = get_or_create_entry(ip);
|
||||
if (entry) {
|
||||
entry->total_connections++;
|
||||
}
|
||||
pthread_mutex_unlock(&g_ban_mutex);
|
||||
}
|
||||
|
||||
void ip_ban_record_failure(const char* ip) {
|
||||
if (!ip || !g_initialized) return;
|
||||
if (!get_config_bool("auth_fail_ban_enabled", 1)) return;
|
||||
|
||||
int threshold = get_config_int("auth_fail_ban_threshold", 3);
|
||||
int window_sec = get_config_int("auth_fail_window_sec", 60);
|
||||
int ban_duration = get_config_int("auth_fail_ban_duration_sec", 300);
|
||||
|
||||
pthread_mutex_lock(&g_ban_mutex);
|
||||
ip_ban_entry_t* entry = get_or_create_entry(ip);
|
||||
if (!entry) {
|
||||
pthread_mutex_unlock(&g_ban_mutex);
|
||||
return;
|
||||
}
|
||||
|
||||
time_t now = time(NULL);
|
||||
|
||||
// Reset window if expired
|
||||
if (entry->first_failure > 0 && (now - entry->first_failure) > window_sec) {
|
||||
entry->failure_count = 0;
|
||||
entry->first_failure = now;
|
||||
}
|
||||
if (entry->first_failure == 0) {
|
||||
entry->first_failure = now;
|
||||
}
|
||||
|
||||
entry->failure_count++;
|
||||
entry->total_failures++;
|
||||
|
||||
DEBUG_TRACE("IP %s auth failure count: %d/%d", ip, entry->failure_count, threshold);
|
||||
|
||||
if (entry->failure_count >= threshold) {
|
||||
int duration = ban_duration;
|
||||
for (int i = 0; i < entry->ban_count && duration < 86400; i++) {
|
||||
duration *= 2;
|
||||
}
|
||||
if (duration > 86400) duration = 86400;
|
||||
|
||||
entry->banned_until = now + duration;
|
||||
entry->ban_count++;
|
||||
entry->failure_count = 0;
|
||||
entry->first_failure = 0;
|
||||
|
||||
DEBUG_WARN("IP %s banned for %d seconds (ban #%d) after %d auth failures",
|
||||
ip, duration, entry->ban_count, threshold);
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&g_ban_mutex);
|
||||
}
|
||||
|
||||
// Record an idle/early-disconnect failure for an IP
|
||||
void ip_ban_record_idle_failure(const char* ip) {
|
||||
if (!ip || !g_initialized) return;
|
||||
if (!get_config_bool("idle_ban_enabled", 1)) return;
|
||||
if (ip_is_whitelisted(ip)) return; // Never record idle failures for whitelisted IPs
|
||||
|
||||
int threshold = get_config_int("idle_ban_threshold", 1);
|
||||
int window_sec = get_config_int("idle_ban_window_sec", 30);
|
||||
int ban_duration = get_config_int("idle_ban_duration_sec", 300);
|
||||
|
||||
pthread_mutex_lock(&g_ban_mutex);
|
||||
ip_ban_entry_t* entry = get_or_create_entry(ip);
|
||||
if (!entry) {
|
||||
pthread_mutex_unlock(&g_ban_mutex);
|
||||
return;
|
||||
}
|
||||
|
||||
time_t now = time(NULL);
|
||||
|
||||
// Reset window if expired
|
||||
if (entry->idle_first_failure > 0 && (now - entry->idle_first_failure) > window_sec) {
|
||||
entry->idle_failure_count = 0;
|
||||
entry->idle_first_failure = now;
|
||||
}
|
||||
if (entry->idle_first_failure == 0) {
|
||||
entry->idle_first_failure = now;
|
||||
}
|
||||
|
||||
entry->idle_failure_count++;
|
||||
entry->total_failures++;
|
||||
|
||||
DEBUG_TRACE("IP %s idle failure count: %d/%d", ip, entry->idle_failure_count, threshold);
|
||||
|
||||
if (entry->idle_failure_count >= threshold) {
|
||||
int duration = ban_duration;
|
||||
for (int i = 0; i < entry->idle_ban_count && duration < 86400; i++) {
|
||||
duration *= 2;
|
||||
}
|
||||
if (duration > 86400) duration = 86400;
|
||||
|
||||
entry->idle_banned_until = now + duration;
|
||||
entry->idle_ban_count++;
|
||||
entry->idle_failure_count = 0;
|
||||
entry->idle_first_failure = 0;
|
||||
|
||||
DEBUG_WARN("IP %s banned for %d seconds (idle ban #%d) after %d idle failures",
|
||||
ip, duration, entry->idle_ban_count, threshold);
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&g_ban_mutex);
|
||||
}
|
||||
|
||||
void ip_ban_record_success(const char* ip) {
|
||||
if (!ip || !g_initialized) return;
|
||||
|
||||
pthread_mutex_lock(&g_ban_mutex);
|
||||
ip_ban_entry_t* entry = get_or_create_entry(ip);
|
||||
if (entry) {
|
||||
entry->failure_count = 0;
|
||||
entry->first_failure = 0;
|
||||
entry->has_authed_successfully = 1;
|
||||
entry->last_success_at = time(NULL);
|
||||
entry->total_successes++;
|
||||
DEBUG_TRACE("IP %s authenticated successfully — failure count cleared", ip);
|
||||
}
|
||||
pthread_mutex_unlock(&g_ban_mutex);
|
||||
}
|
||||
|
||||
void ip_ban_cleanup(void) {
|
||||
if (!g_initialized) return;
|
||||
|
||||
pthread_mutex_lock(&g_ban_mutex);
|
||||
time_t now = time(NULL);
|
||||
int window_sec = get_config_int("auth_fail_window_sec", 60);
|
||||
int idle_window_sec = get_config_int("idle_ban_window_sec", 60);
|
||||
int cleaned = 0;
|
||||
|
||||
for (int i = 0; i < IP_BAN_TABLE_SIZE; i++) {
|
||||
if (g_ban_table[i].state != IP_BAN_ACTIVE) continue;
|
||||
ip_ban_entry_t* entry = &g_ban_table[i];
|
||||
|
||||
// Check auth failure window expiration
|
||||
int auth_ban_expired = (entry->banned_until == 0 || now >= entry->banned_until);
|
||||
int auth_window_expired = (entry->first_failure == 0 || (now - entry->first_failure) > window_sec * 10);
|
||||
|
||||
// Check idle failure window expiration
|
||||
int idle_ban_expired = (entry->idle_banned_until == 0 || now >= entry->idle_banned_until);
|
||||
int idle_window_expired = (entry->idle_first_failure == 0 || (now - entry->idle_first_failure) > idle_window_sec * 10);
|
||||
|
||||
if (auth_ban_expired && auth_window_expired && entry->failure_count == 0 &&
|
||||
idle_ban_expired && idle_window_expired && entry->idle_failure_count == 0) {
|
||||
int retain_sec = 86400; // 24 hours
|
||||
int last_auth_ban_expired_long_ago = (entry->banned_until == 0 ||
|
||||
(now - entry->banned_until) > retain_sec);
|
||||
int last_idle_ban_expired_long_ago = (entry->idle_banned_until == 0 ||
|
||||
(now - entry->idle_banned_until) > retain_sec);
|
||||
|
||||
if (last_auth_ban_expired_long_ago && last_idle_ban_expired_long_ago &&
|
||||
!entry->has_authed_successfully &&
|
||||
entry->ban_count == 0 && entry->idle_ban_count == 0 &&
|
||||
entry->total_connections <= 1) {
|
||||
// Fully clean — never banned, never authenticated, only seen once
|
||||
memset(entry, 0, sizeof(ip_ban_entry_t));
|
||||
cleaned++;
|
||||
} else {
|
||||
// Keep entry permanently — preserve ban_count for escalation.
|
||||
// An IP that has been banned before will always get at least a 24-hour ban
|
||||
// if it fails auth again, regardless of how long it has been away.
|
||||
entry->failure_count = 0;
|
||||
entry->first_failure = 0;
|
||||
entry->idle_failure_count = 0;
|
||||
entry->idle_first_failure = 0;
|
||||
if (last_auth_ban_expired_long_ago) {
|
||||
entry->banned_until = 0;
|
||||
// ban_count intentionally NOT reset — permanent escalation
|
||||
}
|
||||
if (last_idle_ban_expired_long_ago) {
|
||||
entry->idle_banned_until = 0;
|
||||
// idle_ban_count intentionally NOT reset — permanent escalation
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cleaned > 0) {
|
||||
DEBUG_TRACE("IP ban cleanup: freed %d stale entries", cleaned);
|
||||
}
|
||||
pthread_mutex_unlock(&g_ban_mutex);
|
||||
}
|
||||
|
||||
int ip_ban_get_banned_count(void) {
|
||||
if (!g_initialized) return 0;
|
||||
pthread_mutex_lock(&g_ban_mutex);
|
||||
time_t now = time(NULL);
|
||||
int count = 0;
|
||||
for (int i = 0; i < IP_BAN_TABLE_SIZE; i++) {
|
||||
if (g_ban_table[i].state != IP_BAN_ACTIVE) continue;
|
||||
// Count if either auth banned or idle banned
|
||||
if (g_ban_table[i].banned_until > now ||
|
||||
g_ban_table[i].idle_banned_until > now) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&g_ban_mutex);
|
||||
return count;
|
||||
}
|
||||
|
||||
int ip_ban_get_tracked_count(void) {
|
||||
if (!g_initialized) return 0;
|
||||
pthread_mutex_lock(&g_ban_mutex);
|
||||
int count = 0;
|
||||
for (int i = 0; i < IP_BAN_TABLE_SIZE; i++) {
|
||||
if (g_ban_table[i].state == IP_BAN_ACTIVE) count++;
|
||||
}
|
||||
pthread_mutex_unlock(&g_ban_mutex);
|
||||
return count;
|
||||
}
|
||||
|
||||
void ip_ban_log_stats(sqlite3* db) {
|
||||
if (!g_initialized) return;
|
||||
|
||||
static time_t last_log = 0;
|
||||
time_t now = time(NULL);
|
||||
|
||||
if (now - last_log < 300) return;
|
||||
last_log = now;
|
||||
|
||||
// Save to DB every 5 minutes
|
||||
if (db) {
|
||||
ip_ban_save_to_db(db);
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&g_ban_mutex);
|
||||
int auth_banned_count = 0;
|
||||
int idle_banned_count = 0;
|
||||
int tracked_count = 0;
|
||||
int trusted_count = 0;
|
||||
|
||||
for (int i = 0; i < IP_BAN_TABLE_SIZE; i++) {
|
||||
if (g_ban_table[i].state != IP_BAN_ACTIVE) continue;
|
||||
tracked_count++;
|
||||
if (g_ban_table[i].banned_until > now) auth_banned_count++;
|
||||
if (g_ban_table[i].idle_banned_until > now) idle_banned_count++;
|
||||
if (g_ban_table[i].has_authed_successfully) trusted_count++;
|
||||
}
|
||||
pthread_mutex_unlock(&g_ban_mutex);
|
||||
|
||||
int total_banned = auth_banned_count + idle_banned_count;
|
||||
if (total_banned > 0 || tracked_count > 0) {
|
||||
DEBUG_WARN("IP BAN SUMMARY: %d auth-banned, %d idle-banned, %d tracked, %d trusted (ever authed)",
|
||||
auth_banned_count, idle_banned_count, tracked_count, trusted_count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
#ifndef IP_BAN_H
|
||||
#define IP_BAN_H
|
||||
|
||||
// IP Auth Failure Ban System
|
||||
//
|
||||
// Tracks auth failures per IP address and temporarily bans IPs that repeatedly
|
||||
// fail NIP-42 authentication. Uses an in-memory fixed-size hash table — no
|
||||
// database writes on the hot path. State is persisted to the ip_bans table
|
||||
// every 5 minutes and loaded at startup.
|
||||
//
|
||||
// Config keys (all read from the config table at runtime):
|
||||
// auth_fail_ban_enabled bool default: true (0 = disabled)
|
||||
// auth_fail_ban_threshold int default: 3 (failures before ban)
|
||||
// auth_fail_window_sec int default: 60 (window to count failures)
|
||||
// auth_fail_ban_duration_sec int default: 300 (initial ban duration, doubles each time)
|
||||
|
||||
#include <time.h>
|
||||
#include <sqlite3.h>
|
||||
|
||||
// Maximum number of IPs tracked simultaneously (fixed-size, no malloc)
|
||||
#define IP_BAN_TABLE_SIZE 4096
|
||||
|
||||
// Initialize the IP ban table (call once at startup, before loading from DB)
|
||||
void ip_ban_init(void);
|
||||
|
||||
// Load ban state from the ip_bans database table.
|
||||
// Call after ip_ban_init() and after the database is open.
|
||||
void ip_ban_load_from_db(sqlite3* db);
|
||||
|
||||
// Save current ban state to the ip_bans database table.
|
||||
// Called every 5 minutes from the maintenance timer.
|
||||
void ip_ban_save_to_db(sqlite3* db);
|
||||
|
||||
// Check if an IP is currently banned.
|
||||
// Returns 1 if banned (connection should be rejected), 0 if allowed.
|
||||
int ip_ban_is_banned(const char* ip);
|
||||
|
||||
// Record an auth failure for an IP.
|
||||
// Called when a connection is closed due to auth timeout.
|
||||
// May trigger a ban if the threshold is exceeded.
|
||||
void ip_ban_record_failure(const char* ip);
|
||||
|
||||
// Record an idle/early-disconnect failure for an IP.
|
||||
// Called when a connection closes without ever sending REQ or EVENT.
|
||||
// May trigger a ban if the threshold is exceeded.
|
||||
void ip_ban_record_idle_failure(const char* ip);
|
||||
|
||||
// Record a successful auth for an IP.
|
||||
// Sets has_authed_successfully=1 and clears failure count.
|
||||
void ip_ban_record_success(const char* ip);
|
||||
|
||||
// Record a new connection from an IP (increment total_connections).
|
||||
void ip_ban_record_connection(const char* ip);
|
||||
|
||||
// Periodic cleanup: expire old entries (call from the connection age checker).
|
||||
void ip_ban_cleanup(void);
|
||||
|
||||
// Emit a periodic WARN-level log summary of banned IPs (every 5 minutes).
|
||||
// Also saves state to DB.
|
||||
void ip_ban_log_stats(sqlite3* db);
|
||||
|
||||
// Get stats for logging/monitoring
|
||||
int ip_ban_get_banned_count(void);
|
||||
int ip_ban_get_tracked_count(void);
|
||||
|
||||
#endif // IP_BAN_H
|
||||
+298
-36
@@ -162,6 +162,10 @@ int handle_nip11_http_request(struct lws* wsi, const char* accept_header);
|
||||
// Forward declaration for WebSocket relay server
|
||||
int start_websocket_relay(int port_override, int strict_port);
|
||||
|
||||
// Forward declarations for IP ban system
|
||||
void ip_ban_init(void);
|
||||
void ip_ban_load_from_db(sqlite3* db);
|
||||
|
||||
|
||||
// Forward declarations for NIP-13 PoW handling (now in nip013.c)
|
||||
void init_pow_config();
|
||||
@@ -182,6 +186,53 @@ int validate_event_expiration(cJSON* event, char* error_message, size_t error_si
|
||||
|
||||
// Logging functions - REMOVED (replaced by debug system in debug.c)
|
||||
|
||||
static void nostr_log_cb(int level, const char* component, const char* message, void* user_data) {
|
||||
(void)user_data;
|
||||
|
||||
const char* comp = (component && component[0]) ? component : "core";
|
||||
const char* msg = message ? message : "";
|
||||
|
||||
switch (level) {
|
||||
case NOSTR_LOG_LEVEL_ERROR:
|
||||
DEBUG_ERROR("[nostr:%s] %s", comp, msg);
|
||||
break;
|
||||
case NOSTR_LOG_LEVEL_WARN:
|
||||
DEBUG_WARN("[nostr:%s] %s", comp, msg);
|
||||
break;
|
||||
case NOSTR_LOG_LEVEL_INFO:
|
||||
DEBUG_INFO("[nostr:%s] %s", comp, msg);
|
||||
break;
|
||||
case NOSTR_LOG_LEVEL_DEBUG:
|
||||
DEBUG_LOG("[nostr:%s] %s", comp, msg);
|
||||
break;
|
||||
case NOSTR_LOG_LEVEL_TRACE:
|
||||
default:
|
||||
DEBUG_TRACE("[nostr:%s] %s", comp, msg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static nostr_log_level_t nostr_log_level_from_debug_level(int debug_level) {
|
||||
if (debug_level >= DEBUG_LEVEL_TRACE) {
|
||||
return NOSTR_LOG_LEVEL_TRACE;
|
||||
}
|
||||
if (debug_level >= DEBUG_LEVEL_DEBUG) {
|
||||
return NOSTR_LOG_LEVEL_DEBUG;
|
||||
}
|
||||
if (debug_level >= DEBUG_LEVEL_INFO) {
|
||||
return NOSTR_LOG_LEVEL_INFO;
|
||||
}
|
||||
if (debug_level >= DEBUG_LEVEL_WARN) {
|
||||
return NOSTR_LOG_LEVEL_WARN;
|
||||
}
|
||||
if (debug_level >= DEBUG_LEVEL_ERROR) {
|
||||
return NOSTR_LOG_LEVEL_ERROR;
|
||||
}
|
||||
|
||||
// Production-safe default for systemd/journald deployments.
|
||||
return NOSTR_LOG_LEVEL_INFO;
|
||||
}
|
||||
|
||||
// Update subscription manager configuration from config system
|
||||
void update_subscription_manager_config(void) {
|
||||
g_subscription_manager.max_subscriptions_per_client = get_config_int("max_subscriptions_per_client", MAX_SUBSCRIPTIONS_PER_CLIENT);
|
||||
@@ -566,6 +617,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 +742,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 +790,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 +863,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 +910,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;
|
||||
}
|
||||
|
||||
@@ -864,7 +1072,7 @@ static int is_only_kind_99999_request(cJSON* filters) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* kinds = cJSON_GetObjectItem(filter, "kinds");
|
||||
cJSON* kinds = cJSON_GetObjectItemCaseSensitive(filter, "kinds");
|
||||
if (!kinds || !cJSON_IsArray(kinds)) {
|
||||
// Filter has no kinds or kinds is not an array - not a pure 99999 request
|
||||
return 0;
|
||||
@@ -1099,6 +1307,13 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
|
||||
continue;
|
||||
}
|
||||
|
||||
// NIP-01: limit:0 means return zero stored events — skip SQL entirely, just send EOSE
|
||||
cJSON* limit_check = cJSON_GetObjectItemCaseSensitive(filter, "limit");
|
||||
if (limit_check && cJSON_IsNumber(limit_check) && (int)cJSON_GetNumberValue(limit_check) == 0) {
|
||||
DEBUG_LOG("Filter has limit:0 — skipping query, sending EOSE immediately (NIP-01 compliance)");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reset bind params for this filter
|
||||
free_bind_params(bind_params, bind_param_count);
|
||||
bind_params = NULL;
|
||||
@@ -1115,7 +1330,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) {
|
||||
@@ -1143,7 +1358,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
|
||||
@@ -1183,7 +1398,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
|
||||
@@ -1240,8 +1455,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);
|
||||
|
||||
@@ -1273,7 +1488,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) {
|
||||
@@ -1301,7 +1516,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);
|
||||
@@ -1309,7 +1524,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);
|
||||
@@ -1322,7 +1537,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) {
|
||||
@@ -1372,7 +1587,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++;
|
||||
@@ -1397,9 +1616,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)) {
|
||||
@@ -1409,21 +1626,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);
|
||||
@@ -1463,7 +1683,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;
|
||||
@@ -1476,7 +1696,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)");
|
||||
@@ -1484,6 +1704,7 @@ int is_authorized_admin_event(cJSON* event, char* error_buffer, size_t error_buf
|
||||
}
|
||||
|
||||
int targets_this_relay = 0;
|
||||
const char* relay_pubkey = get_config_value("relay_pubkey");
|
||||
cJSON *tag;
|
||||
cJSON_ArrayForEach(tag, tags) {
|
||||
if (cJSON_IsArray(tag)) {
|
||||
@@ -1495,7 +1716,6 @@ int is_authorized_admin_event(cJSON* event, char* error_buffer, size_t error_buf
|
||||
strcmp(tag_name->valuestring, "p") == 0) {
|
||||
|
||||
// Compare with our relay pubkey
|
||||
const char* relay_pubkey = get_config_value("relay_pubkey");
|
||||
if (relay_pubkey && strcmp(tag_value->valuestring, relay_pubkey) == 0) {
|
||||
targets_this_relay = 1;
|
||||
break;
|
||||
@@ -1503,6 +1723,7 @@ int is_authorized_admin_event(cJSON* event, char* error_buffer, size_t error_buf
|
||||
}
|
||||
}
|
||||
}
|
||||
if (relay_pubkey) free((char*)relay_pubkey);
|
||||
|
||||
if (!targets_this_relay) {
|
||||
// Admin event for different relay - not an error, just not for us
|
||||
@@ -1511,7 +1732,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");
|
||||
@@ -1523,6 +1744,7 @@ int is_authorized_admin_event(cJSON* event, char* error_buffer, size_t error_buf
|
||||
if (!admin_pubkey || strlen(admin_pubkey) == 0) {
|
||||
DEBUG_WARN("Unauthorized admin event attempt: no admin pubkey configured");
|
||||
snprintf(error_buffer, error_buffer_size, "Unauthorized admin event attempt: no admin configured");
|
||||
if (admin_pubkey) free((char*)admin_pubkey);
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -1535,6 +1757,7 @@ int is_authorized_admin_event(cJSON* event, char* error_buffer, size_t error_buf
|
||||
DEBUG_WARN(warning_msg);
|
||||
DEBUG_INFO("DEBUG: Pubkey comparison failed - event pubkey != admin pubkey");
|
||||
snprintf(error_buffer, error_buffer_size, "Unauthorized admin event attempt: invalid admin pubkey");
|
||||
free((char*)admin_pubkey);
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -1543,9 +1766,11 @@ int is_authorized_admin_event(cJSON* event, char* error_buffer, size_t error_buf
|
||||
if (nostr_verify_event_signature(event) != 0) {
|
||||
DEBUG_WARN("Unauthorized admin event attempt: invalid signature");
|
||||
snprintf(error_buffer, error_buffer_size, "Unauthorized admin event attempt: signature verification failed");
|
||||
free((char*)admin_pubkey);
|
||||
return -1;
|
||||
}
|
||||
|
||||
free((char*)admin_pubkey);
|
||||
|
||||
// All checks passed - authorized admin event
|
||||
return 0;
|
||||
@@ -1754,6 +1979,12 @@ int main(int argc, char* argv[]) {
|
||||
|
||||
DEBUG_LOG("Nostr library initialized");
|
||||
|
||||
// Route nostr_core_lib logs into relay logging pipeline (stdout/stderr -> journald or relay.log)
|
||||
nostr_set_log_callback(nostr_log_cb, NULL);
|
||||
nostr_log_level_t nostr_level = nostr_log_level_from_debug_level(cli_options.debug_level);
|
||||
nostr_set_log_level(nostr_level);
|
||||
DEBUG_INFO("Nostr callback logging initialized at level %d", (int)nostr_level);
|
||||
|
||||
// Check if this is first-time startup or existing relay
|
||||
if (is_first_time_startup()) {
|
||||
DEBUG_LOG("First-time startup detected");
|
||||
@@ -1902,6 +2133,21 @@ int main(int argc, char* argv[]) {
|
||||
}
|
||||
DEBUG_LOG("Existing database initialized");
|
||||
|
||||
// Keep relay_version in sync with the compiled binary version on every startup
|
||||
if (update_config_in_table("relay_version", CRELAY_VERSION) != 0) {
|
||||
DEBUG_ERROR("Failed to synchronize relay_version with compiled CRELAY_VERSION");
|
||||
cleanup_configuration_system();
|
||||
free(relay_pubkey);
|
||||
for (int i = 0; existing_files[i]; i++) {
|
||||
free(existing_files[i]);
|
||||
}
|
||||
free(existing_files);
|
||||
nostr_cleanup();
|
||||
close_database();
|
||||
return 1;
|
||||
}
|
||||
DEBUG_INFO("Synchronized relay_version to %s", CRELAY_VERSION);
|
||||
|
||||
// Apply CLI overrides atomically (now that database is initialized)
|
||||
if (apply_cli_overrides_atomic(&cli_options) != 0) {
|
||||
DEBUG_ERROR("Failed to apply CLI overrides for existing relay");
|
||||
@@ -1989,9 +2235,24 @@ 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 and load persisted state from database
|
||||
ip_ban_init();
|
||||
ip_ban_load_from_db(g_db);
|
||||
|
||||
// Start WebSocket Nostr relay server (port from CLI override or configuration)
|
||||
int result = start_websocket_relay(cli_options.port_override, cli_options.strict_port); // Use CLI port override if specified, otherwise config
|
||||
|
||||
@@ -2004,6 +2265,7 @@ int main(int argc, char* argv[]) {
|
||||
pthread_mutex_destroy(&g_subscription_manager.subscriptions_lock);
|
||||
pthread_mutex_destroy(&g_subscription_manager.ip_tracking_lock);
|
||||
|
||||
nostr_set_log_callback(NULL, NULL);
|
||||
nostr_cleanup();
|
||||
close_database();
|
||||
|
||||
|
||||
+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 3
|
||||
#define CRELAY_VERSION "v1.2.3"
|
||||
#define CRELAY_VERSION_PATCH 56
|
||||
#define CRELAY_VERSION "v1.2.56"
|
||||
|
||||
// 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,
|
||||
|
||||
+22
-3
@@ -12,7 +12,9 @@
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
#include <stdio.h>
|
||||
#include "websockets.h"
|
||||
#include "ip_ban.h"
|
||||
|
||||
|
||||
// Forward declaration for notice message function
|
||||
@@ -93,13 +95,22 @@ void handle_nip42_auth_signed_event(struct lws* wsi, struct per_session_data* ps
|
||||
|
||||
// Verify authentication using existing request_validator function
|
||||
// Note: nostr_nip42_verify_auth_event doesn't extract pubkey, we need to do that separately
|
||||
char relay_url[RELAY_URL_MAX_LENGTH];
|
||||
const char* configured_relay_url = get_config_value("relay_url");
|
||||
if (configured_relay_url && configured_relay_url[0] != '\0') {
|
||||
snprintf(relay_url, sizeof(relay_url), "%s", configured_relay_url);
|
||||
} else {
|
||||
int relay_port = (g_relay_port > 0) ? g_relay_port : get_config_int("relay_port", DEFAULT_PORT);
|
||||
snprintf(relay_url, sizeof(relay_url), "ws://127.0.0.1:%d", relay_port);
|
||||
}
|
||||
|
||||
int result = nostr_nip42_verify_auth_event(auth_event, challenge_copy,
|
||||
"ws://localhost:8888", 600); // 10 minutes tolerance
|
||||
relay_url, 600); // 10 minutes tolerance
|
||||
|
||||
char authenticated_pubkey[65] = {0};
|
||||
if (result == 0) {
|
||||
// Extract pubkey from the auth event
|
||||
cJSON* pubkey_json = cJSON_GetObjectItem(auth_event, "pubkey");
|
||||
cJSON* pubkey_json = cJSON_GetObjectItemCaseSensitive(auth_event, "pubkey");
|
||||
if (pubkey_json && cJSON_IsString(pubkey_json)) {
|
||||
const char* pubkey_str = cJSON_GetStringValue(pubkey_json);
|
||||
if (pubkey_str && strlen(pubkey_str) == 64) {
|
||||
@@ -125,8 +136,16 @@ void handle_nip42_auth_signed_event(struct lws* wsi, struct per_session_data* ps
|
||||
memset(pss->active_challenge, 0, sizeof(pss->active_challenge));
|
||||
pss->challenge_expires = 0;
|
||||
pss->auth_challenge_sent = 0;
|
||||
// Mark session as active - client sent AUTH
|
||||
pss->session_active = 1;
|
||||
pthread_mutex_unlock(&pss->session_lock);
|
||||
|
||||
|
||||
// Cancel the auth timeout — client has authenticated, keep connection open
|
||||
lws_set_timeout(wsi, NO_PENDING_TIMEOUT, 0);
|
||||
|
||||
// Record successful auth for this IP — marks it as trusted
|
||||
ip_ban_record_success(pss->client_ip);
|
||||
|
||||
send_notice_message(wsi, pss, "NIP-42 authentication successful");
|
||||
} else {
|
||||
// Authentication failed
|
||||
|
||||
+17
-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) ||
|
||||
@@ -304,9 +304,11 @@ int nostr_validate_unified_request(const char* json_string, size_t json_length)
|
||||
const char* admin_pubkey = get_config_value("admin_pubkey");
|
||||
if (admin_pubkey && strcmp(event_pubkey, admin_pubkey) == 0) {
|
||||
// Valid admin event - bypass remaining validation
|
||||
free((char*)admin_pubkey);
|
||||
cJSON_Delete(event);
|
||||
return NOSTR_SUCCESS;
|
||||
}
|
||||
if (admin_pubkey) free((char*)admin_pubkey);
|
||||
// Not from admin - continue with normal validation
|
||||
}
|
||||
|
||||
@@ -380,7 +382,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 +597,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 +613,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 +820,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 +898,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 */
|
||||
|
||||
+66
-61
@@ -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);
|
||||
}
|
||||
@@ -266,10 +266,13 @@ int validate_subscription_id(const char* sub_id) {
|
||||
}
|
||||
|
||||
// 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 == '+')) {
|
||||
(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");
|
||||
|
||||
+480
-74
@@ -30,6 +30,7 @@
|
||||
#include "embedded_web_content.h" // Embedded web content
|
||||
#include "api.h" // API for embedded files
|
||||
#include "dm_admin.h" // DM admin functions including NIP-17
|
||||
#include "ip_ban.h" // IP auth failure ban system
|
||||
|
||||
// Forward declarations for logging functions
|
||||
|
||||
@@ -64,6 +65,7 @@ int remove_subscription_from_manager(const char* sub_id, struct lws* wsi);
|
||||
// Forward declarations for event handling
|
||||
int handle_event_message(cJSON* event, char* error_message, size_t error_size);
|
||||
int nostr_validate_unified_request(const char* json_string, size_t json_length);
|
||||
int event_id_exists_in_db(const char* event_id);
|
||||
|
||||
// Forward declarations for admin event processing
|
||||
int process_admin_event_in_config(cJSON* event, char* error_message, size_t error_size, struct lws* wsi);
|
||||
@@ -114,6 +116,86 @@ extern struct lws_context *ws_context;
|
||||
// Global subscription manager
|
||||
struct subscription_manager g_subscription_manager;
|
||||
|
||||
// Actual relay port bound by libwebsockets at runtime (after fallback/retry logic)
|
||||
int g_relay_port = DEFAULT_PORT;
|
||||
|
||||
// Global connection list for idle connection tracking
|
||||
// Tracks ALL WebSocket connections (not just subscribed ones)
|
||||
// so the periodic timer can find and close idle connections
|
||||
#define MAX_TRACKED_CONNECTIONS 4096
|
||||
|
||||
typedef struct {
|
||||
struct lws* wsi;
|
||||
struct per_session_data* pss;
|
||||
} tracked_connection_t;
|
||||
|
||||
static tracked_connection_t g_connections[MAX_TRACKED_CONNECTIONS];
|
||||
static int g_connection_count = 0;
|
||||
static pthread_mutex_t g_connections_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
int get_active_connection_count(void) { return g_connection_count; }
|
||||
|
||||
static void connection_list_add(struct lws* wsi, struct per_session_data* pss) {
|
||||
pthread_mutex_lock(&g_connections_lock);
|
||||
for (int i = 0; i < MAX_TRACKED_CONNECTIONS; i++) {
|
||||
if (g_connections[i].wsi == NULL) {
|
||||
g_connections[i].wsi = wsi;
|
||||
g_connections[i].pss = pss;
|
||||
g_connection_count++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&g_connections_lock);
|
||||
}
|
||||
|
||||
static void connection_list_remove(struct lws* wsi) {
|
||||
pthread_mutex_lock(&g_connections_lock);
|
||||
for (int i = 0; i < MAX_TRACKED_CONNECTIONS; i++) {
|
||||
if (g_connections[i].wsi == wsi) {
|
||||
g_connections[i].wsi = NULL;
|
||||
g_connections[i].pss = NULL;
|
||||
g_connection_count--;
|
||||
break;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&g_connections_lock);
|
||||
}
|
||||
|
||||
// Check all tracked connections for idle timeout and close them
|
||||
// Called from the periodic maintenance timer (every 60 seconds)
|
||||
static void check_idle_connections(int idle_timeout_sec) {
|
||||
if (idle_timeout_sec <= 0) return;
|
||||
|
||||
time_t now = time(NULL);
|
||||
|
||||
// Collect WSIs to close outside the lock to avoid deadlock
|
||||
struct lws* to_close[MAX_TRACKED_CONNECTIONS];
|
||||
int close_count = 0;
|
||||
|
||||
pthread_mutex_lock(&g_connections_lock);
|
||||
for (int i = 0; i < MAX_TRACKED_CONNECTIONS; i++) {
|
||||
if (g_connections[i].wsi == NULL || g_connections[i].pss == NULL) continue;
|
||||
struct per_session_data* pss = g_connections[i].pss;
|
||||
if (pss->session_active) continue; // Already active — skip
|
||||
if (pss->connection_established <= 0) continue;
|
||||
time_t age = now - pss->connection_established;
|
||||
if (age >= idle_timeout_sec) {
|
||||
to_close[close_count++] = g_connections[i].wsi;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&g_connections_lock);
|
||||
|
||||
for (int i = 0; i < close_count; i++) {
|
||||
// Get pss again safely — it may have been freed if connection closed between lock release and here
|
||||
struct per_session_data* pss = (struct per_session_data*)lws_wsi_user(to_close[i]);
|
||||
if (!pss) continue;
|
||||
DEBUG_LOG("Closing idle connection from %s (no REQ/EVENT after %d seconds)",
|
||||
pss->client_ip, idle_timeout_sec);
|
||||
lws_close_reason(to_close[i], LWS_CLOSE_STATUS_POLICY_VIOLATION,
|
||||
(unsigned char*)"Idle connection timeout", 23);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Message queue functions for proper libwebsockets pattern
|
||||
@@ -135,6 +217,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 +276,66 @@ int queue_message(struct lws* wsi, struct per_session_data* pss, const char* mes
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-copy variant of queue_message. The caller allocates a buffer of
|
||||
* (LWS_PRE + length) bytes, writes the message at (buf + LWS_PRE), then
|
||||
* passes ownership to the queue. The queue will free buf when done.
|
||||
* No memcpy is performed — eliminates one copy per queued message.
|
||||
*
|
||||
* @param wsi WebSocket instance
|
||||
* @param pss Per-session data containing message queue
|
||||
* @param buf Pre-allocated buffer of size (LWS_PRE + length); ownership transferred
|
||||
* @param length Length of message (NOT including LWS_PRE)
|
||||
* @param type LWS_WRITE_* type
|
||||
* @return 0 on success, -1 on error (buf is freed on error)
|
||||
*/
|
||||
int queue_message_take_ownership(struct lws* wsi, struct per_session_data* pss, unsigned char* buf, size_t length, enum lws_write_protocol type) {
|
||||
if (!wsi || !pss || !buf || length == 0) {
|
||||
DEBUG_ERROR("queue_message_take_ownership: invalid parameters");
|
||||
free(buf);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Drop message if queue is full
|
||||
if (pss->message_queue_count >= MAX_MESSAGE_QUEUE_SIZE) {
|
||||
DEBUG_WARN("queue_message_take_ownership: queue full (%d), dropping message",
|
||||
pss->message_queue_count);
|
||||
free(buf);
|
||||
return -1;
|
||||
}
|
||||
|
||||
struct message_queue_node* node = malloc(sizeof(struct message_queue_node));
|
||||
if (!node) {
|
||||
DEBUG_ERROR("queue_message_take_ownership: failed to allocate queue node");
|
||||
free(buf);
|
||||
return -1;
|
||||
}
|
||||
|
||||
node->data = buf; // buf already has LWS_PRE prefix — no copy needed
|
||||
node->length = length;
|
||||
node->type = type;
|
||||
node->next = NULL;
|
||||
|
||||
pthread_mutex_lock(&pss->session_lock);
|
||||
if (!pss->message_queue_head) {
|
||||
pss->message_queue_head = node;
|
||||
pss->message_queue_tail = node;
|
||||
} else {
|
||||
pss->message_queue_tail->next = node;
|
||||
pss->message_queue_tail = node;
|
||||
}
|
||||
pss->message_queue_count++;
|
||||
pthread_mutex_unlock(&pss->session_lock);
|
||||
|
||||
if (!pss->writeable_requested) {
|
||||
pss->writeable_requested = 1;
|
||||
lws_callback_on_writable(wsi);
|
||||
}
|
||||
|
||||
DEBUG_TRACE("Queued message (zero-copy): len=%zu, queue_count=%d", length, pss->message_queue_count);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process message queue when the socket becomes writeable.
|
||||
* This function is called from LWS_CALLBACK_SERVER_WRITEABLE.
|
||||
@@ -260,6 +410,12 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
||||
switch (reason) {
|
||||
case LWS_CALLBACK_HTTP:
|
||||
// Handle HTTP requests
|
||||
// Mark session as active so HTTP requests don't trigger idle ban
|
||||
if (pss) {
|
||||
pthread_mutex_lock(&pss->session_lock);
|
||||
pss->session_active = 1;
|
||||
pthread_mutex_unlock(&pss->session_lock);
|
||||
}
|
||||
{
|
||||
char *requested_uri = (char *)in;
|
||||
|
||||
@@ -313,6 +469,12 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
||||
int is_nip11_request = (strstr(accept_header, "application/nostr+json") != NULL);
|
||||
|
||||
if (is_nip11_request) {
|
||||
// Mark session as active so HTTP requests don't trigger idle ban
|
||||
if (pss) {
|
||||
pthread_mutex_lock(&pss->session_lock);
|
||||
pss->session_active = 1;
|
||||
pthread_mutex_unlock(&pss->session_lock);
|
||||
}
|
||||
// Handle NIP-11 request
|
||||
if (handle_nip11_http_request(wsi, accept_header) == 0) {
|
||||
return 0; // Successfully handled
|
||||
@@ -476,6 +638,50 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
||||
memset(pss->active_challenge, 0, sizeof(pss->active_challenge));
|
||||
pss->challenge_created = 0;
|
||||
pss->challenge_expires = 0;
|
||||
|
||||
// Mark as WebSocket connection (not HTTP)
|
||||
pss->is_websocket = 1;
|
||||
|
||||
// Register in global connection list for idle tracking
|
||||
connection_list_add(wsi, pss);
|
||||
|
||||
// Record connection for stats tracking
|
||||
ip_ban_record_connection(pss->client_ip);
|
||||
|
||||
// Check IP ban using the resolved client IP (which may be from X-Forwarded-For).
|
||||
// This must happen AFTER pss->client_ip is populated so the same IP string
|
||||
// is used for both ban recording (at CLOSED) and ban checking (here).
|
||||
if (ip_ban_is_banned(pss->client_ip)) {
|
||||
DEBUG_LOG("Rejecting banned IP %s at connection establishment", pss->client_ip);
|
||||
return -1; // Close connection immediately — no challenge, no processing
|
||||
}
|
||||
|
||||
// Initialize session activity tracking
|
||||
pss->session_active = 0;
|
||||
pss->idle_timeout_sec = get_config_int("idle_connection_timeout_sec", 30);
|
||||
|
||||
// Set idle timeout for ALL connections (not just auth-required)
|
||||
// This catches bots that connect and do nothing
|
||||
if (pss->idle_timeout_sec > 0) {
|
||||
lws_set_timeout(wsi, PENDING_TIMEOUT_AWAITING_PING, pss->idle_timeout_sec);
|
||||
DEBUG_TRACE("Idle timeout set: %d seconds for connection from %s",
|
||||
pss->idle_timeout_sec, pss->client_ip);
|
||||
}
|
||||
|
||||
// Also set auth timeout if auth is required (separate concern)
|
||||
if (pss->nip42_auth_required_events || pss->nip42_auth_required_subscriptions) {
|
||||
int auth_timeout = get_config_int("nip42_auth_timeout_sec", 10);
|
||||
// Use the shorter of the two timeouts
|
||||
int effective_timeout = (pss->idle_timeout_sec > 0 && pss->idle_timeout_sec < auth_timeout)
|
||||
? pss->idle_timeout_sec
|
||||
: auth_timeout;
|
||||
if (effective_timeout > 0) {
|
||||
lws_set_timeout(wsi, PENDING_TIMEOUT_AWAITING_PING, effective_timeout);
|
||||
DEBUG_TRACE("Auth timeout set: %d seconds for unauthenticated connection from %s",
|
||||
effective_timeout, pss->client_ip);
|
||||
}
|
||||
}
|
||||
|
||||
DEBUG_TRACE("WebSocket connection initialization complete");
|
||||
break;
|
||||
|
||||
@@ -572,6 +778,14 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
||||
const char* msg_type = cJSON_GetStringValue(type);
|
||||
|
||||
if (strcmp(msg_type, "EVENT") == 0) {
|
||||
// Mark session as active - client sent EVENT
|
||||
pthread_mutex_lock(&pss->session_lock);
|
||||
pss->session_active = 1;
|
||||
pthread_mutex_unlock(&pss->session_lock);
|
||||
|
||||
// Cancel idle timeout - this is legitimate usage
|
||||
lws_set_timeout(wsi, NO_PENDING_TIMEOUT, 0);
|
||||
|
||||
// Handle EVENT message
|
||||
cJSON* event = cJSON_GetArrayItem(json, 1);
|
||||
if (event && cJSON_IsObject(event)) {
|
||||
@@ -601,6 +815,27 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Early duplicate check: skip expensive crypto for events already in DB.
|
||||
// The event id is a 64-char hex string — look it up via primary key index (~10μs).
|
||||
// This must happen AFTER we have the id string but BEFORE signature verification.
|
||||
cJSON* id_obj_dup = cJSON_GetObjectItemCaseSensitive(event, "id");
|
||||
if (id_obj_dup && cJSON_IsString(id_obj_dup)) {
|
||||
const char* event_id_str = cJSON_GetStringValue(id_obj_dup);
|
||||
if (event_id_str && event_id_exists_in_db(event_id_str)) {
|
||||
// Already have this event — send OK true and skip crypto
|
||||
DEBUG_TRACE("Duplicate event %s — skipping signature verification", event_id_str);
|
||||
char ok_msg[128];
|
||||
snprintf(ok_msg, sizeof(ok_msg),
|
||||
"[\"OK\",\"%s\",true,\"duplicate: already have this event\"]",
|
||||
event_id_str);
|
||||
size_t ok_len = strlen(ok_msg);
|
||||
queue_message(wsi, pss, ok_msg, ok_len, LWS_WRITE_TEXT);
|
||||
free(event_json_str);
|
||||
cJSON_Delete(json);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Call unified validator with JSON string
|
||||
size_t event_json_len = strlen(event_json_str);
|
||||
int validation_result = nostr_validate_unified_request(event_json_str, event_json_len);
|
||||
@@ -646,7 +881,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
||||
if (result == 0) {
|
||||
// Check if event has protected tag ["-"]
|
||||
int is_protected_event = 0;
|
||||
cJSON* tags = cJSON_GetObjectItem(event, "tags");
|
||||
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
|
||||
if (tags && cJSON_IsArray(tags)) {
|
||||
cJSON* tag = NULL;
|
||||
cJSON_ArrayForEach(tag, tags) {
|
||||
@@ -673,7 +908,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
||||
DEBUG_WARN("Protected event rejected: protected events not enabled");
|
||||
} else {
|
||||
// Protected events enabled - check authentication
|
||||
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
|
||||
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
|
||||
const char* event_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
|
||||
|
||||
if (!pss || !pss->authenticated ||
|
||||
@@ -690,7 +925,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
||||
|
||||
// Check for admin events (kind 23456) and intercept them
|
||||
if (result == 0) {
|
||||
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
|
||||
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
|
||||
if (kind_obj && cJSON_IsNumber(kind_obj)) {
|
||||
int event_kind = (int)cJSON_GetNumberValue(kind_obj);
|
||||
|
||||
@@ -768,44 +1003,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 +1117,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
||||
}
|
||||
|
||||
// Send OK response
|
||||
cJSON* event_id = cJSON_GetObjectItem(event, "id");
|
||||
cJSON* event_id = cJSON_GetObjectItemCaseSensitive(event, "id");
|
||||
if (event_id && cJSON_IsString(event_id)) {
|
||||
cJSON* response = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(response, cJSON_CreateString("OK"));
|
||||
@@ -907,12 +1156,49 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
||||
} else {
|
||||
send_notice_message(wsi, pss, "NIP-42 authentication required for subscriptions");
|
||||
DEBUG_WARN("REQ rejected: NIP-42 authentication required");
|
||||
|
||||
// Auth timeout: close connection if challenge was sent but client
|
||||
// hasn't authenticated within nip42_auth_timeout_sec seconds
|
||||
int auth_timeout = get_config_int("nip42_auth_timeout_sec", 10);
|
||||
if (auth_timeout > 0 && pss->connection_established > 0) {
|
||||
time_t connection_age = time(NULL) - pss->connection_established;
|
||||
if (connection_age >= auth_timeout) {
|
||||
DEBUG_LOG("Closing unauthenticated connection from %s after %ld seconds (timeout=%d)",
|
||||
pss->client_ip, connection_age, auth_timeout);
|
||||
lws_close_reason(wsi, LWS_CLOSE_STATUS_POLICY_VIOLATION,
|
||||
(unsigned char*)"Authentication timeout", 22);
|
||||
cJSON_Delete(json);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
cJSON_Delete(json);
|
||||
// Note: complete_message points to reassembly_buffer, which is managed separately
|
||||
// and should not be freed here - it will be cleaned up in LWS_CALLBACK_CLOSED
|
||||
return 0;
|
||||
}
|
||||
|
||||
// WoT read restriction check (wot_enabled == 2)
|
||||
// After NIP-42 auth, check if authenticated pubkey is in WoT whitelist
|
||||
if (pss && pss->authenticated && get_config_int("wot_enabled", 0) == 2) {
|
||||
// Client is authenticated - check if their pubkey is in the WoT whitelist
|
||||
extern int check_database_auth_rules(const char* pubkey, const char* operation, const char* resource_hash);
|
||||
int wot_result = check_database_auth_rules(pss->authenticated_pubkey, "subscription", NULL);
|
||||
if (wot_result != 0) {
|
||||
send_notice_message(wsi, pss, "restricted: your pubkey is not in this relay's web of trust");
|
||||
DEBUG_INFO("REQ rejected: pubkey %s not in WoT whitelist", pss->authenticated_pubkey);
|
||||
cJSON_Delete(json);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Mark session as active - client sent REQ
|
||||
pthread_mutex_lock(&pss->session_lock);
|
||||
pss->session_active = 1;
|
||||
pthread_mutex_unlock(&pss->session_lock);
|
||||
|
||||
// Cancel idle timeout - this is legitimate usage
|
||||
lws_set_timeout(wsi, NO_PENDING_TIMEOUT, 0);
|
||||
|
||||
DEBUG_TRACE("REQ message passed authentication check");
|
||||
|
||||
@@ -1211,15 +1497,23 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
||||
const char* msg_type = cJSON_GetStringValue(type);
|
||||
|
||||
if (strcmp(msg_type, "EVENT") == 0) {
|
||||
// Mark session as active - client sent EVENT
|
||||
pthread_mutex_lock(&pss->session_lock);
|
||||
pss->session_active = 1;
|
||||
pthread_mutex_unlock(&pss->session_lock);
|
||||
|
||||
// Cancel idle timeout - this is legitimate usage
|
||||
lws_set_timeout(wsi, NO_PENDING_TIMEOUT, 0);
|
||||
|
||||
// Extract event for kind-specific NIP-42 authentication check
|
||||
cJSON* event_obj = cJSON_GetArrayItem(json, 1);
|
||||
if (event_obj && cJSON_IsObject(event_obj)) {
|
||||
// Extract event kind for kind-specific NIP-42 authentication check
|
||||
cJSON* kind_obj = cJSON_GetObjectItem(event_obj, "kind");
|
||||
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event_obj, "kind");
|
||||
int event_kind = kind_obj && cJSON_IsNumber(kind_obj) ? (int)cJSON_GetNumberValue(kind_obj) : -1;
|
||||
|
||||
// Extract pubkey for debugging
|
||||
cJSON* pubkey_obj = cJSON_GetObjectItem(event_obj, "pubkey");
|
||||
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event_obj, "pubkey");
|
||||
const char* event_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : "unknown";
|
||||
|
||||
// Check if NIP-42 authentication is required for this event kind or globally
|
||||
@@ -1228,7 +1522,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
||||
// Special case: allow kind 14 DMs addressed to relay to bypass auth (admin commands)
|
||||
int bypass_auth = 0;
|
||||
if (event_kind == 14 && event_obj && cJSON_IsObject(event_obj)) {
|
||||
cJSON* tags = cJSON_GetObjectItem(event_obj, "tags");
|
||||
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event_obj, "tags");
|
||||
if (tags && cJSON_IsArray(tags)) {
|
||||
const char* relay_pubkey = get_config_value("relay_pubkey");
|
||||
if (relay_pubkey) {
|
||||
@@ -1246,6 +1540,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
||||
}
|
||||
}
|
||||
}
|
||||
free((char*)relay_pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1258,6 +1553,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
||||
} else {
|
||||
DEBUG_INFO("DEBUG: Kind 23456 event but pubkey mismatch or no admin pubkey");
|
||||
}
|
||||
if (admin_pubkey) free((char*)admin_pubkey);
|
||||
}
|
||||
|
||||
if (pss && auth_required && !pss->authenticated && !bypass_auth) {
|
||||
@@ -1308,6 +1604,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 +1669,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 +1696,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 +1713,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 +1791,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 +1905,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 +1946,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);
|
||||
@@ -1629,9 +1974,17 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
||||
cJSON* sub_id = cJSON_GetArrayItem(json, 1);
|
||||
|
||||
if (sub_id && cJSON_IsString(sub_id)) {
|
||||
const char* subscription_id = cJSON_GetStringValue(sub_id);
|
||||
const char* subscription_id = cJSON_GetStringValue(sub_id);
|
||||
|
||||
DEBUG_TRACE("Processing REQ message for subscription %s", subscription_id);
|
||||
// Mark session as active - client sent REQ
|
||||
pthread_mutex_lock(&pss->session_lock);
|
||||
pss->session_active = 1;
|
||||
pthread_mutex_unlock(&pss->session_lock);
|
||||
|
||||
// Cancel idle timeout - this is legitimate usage
|
||||
lws_set_timeout(wsi, NO_PENDING_TIMEOUT, 0);
|
||||
|
||||
DEBUG_TRACE("Processing REQ message for subscription %s", subscription_id);
|
||||
|
||||
// Validate subscription ID before processing
|
||||
if (!subscription_id) {
|
||||
@@ -1896,7 +2249,10 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
||||
|
||||
case LWS_CALLBACK_CLOSED:
|
||||
DEBUG_TRACE("WebSocket connection closed");
|
||||
|
||||
|
||||
// Remove from global connection list (must happen before pss cleanup)
|
||||
connection_list_remove(wsi);
|
||||
|
||||
// Enhanced closure logging with detailed diagnostics
|
||||
if (pss) {
|
||||
// Calculate connection duration
|
||||
@@ -1909,6 +2265,26 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
||||
if (g_shutdown_flag || !g_server_running) {
|
||||
reason = "server_shutdown";
|
||||
}
|
||||
|
||||
// Record failure if connection closed without ever becoming active
|
||||
// This catches:
|
||||
// 1. Idle connections that timed out (never sent REQ/EVENT/AUTH)
|
||||
// 2. Early disconnects (client closed before sending REQ/EVENT/AUTH)
|
||||
// Only record for WebSocket connections, not HTTP requests (NIP-11, embedded files)
|
||||
if (!pss->session_active && pss->is_websocket && strlen(pss->client_ip) > 0) {
|
||||
// Use separate idle failure recording (has its own threshold/duration)
|
||||
ip_ban_record_idle_failure(pss->client_ip);
|
||||
DEBUG_LOG("Recording idle/early-disconnect failure for IP %s (connected %ld seconds)",
|
||||
pss->client_ip,
|
||||
time(NULL) - pss->connection_established);
|
||||
}
|
||||
// Legacy: record auth failure if auth was required but not completed
|
||||
else if (!pss->authenticated &&
|
||||
(pss->nip42_auth_required_events || pss->nip42_auth_required_subscriptions) &&
|
||||
pss->auth_challenge_sent &&
|
||||
strlen(pss->client_ip) > 0) {
|
||||
ip_ban_record_failure(pss->client_ip);
|
||||
}
|
||||
|
||||
// Format authentication status
|
||||
char auth_status[80];
|
||||
@@ -2075,6 +2451,11 @@ static void check_connection_age(int max_connection_seconds) {
|
||||
|
||||
// Cleanup
|
||||
free(checked_wsis);
|
||||
|
||||
// Periodic IP ban maintenance: cleanup expired entries, log stats, save to DB
|
||||
ip_ban_cleanup();
|
||||
extern sqlite3* g_db;
|
||||
ip_ban_log_stats(g_db);
|
||||
}
|
||||
|
||||
// WebSocket protocol definition
|
||||
@@ -2233,6 +2614,9 @@ int start_websocket_relay(int port_override, int strict_port) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Persist the actual bound port for components that need the runtime relay URL
|
||||
g_relay_port = actual_port;
|
||||
|
||||
char startup_msg[256];
|
||||
if (actual_port != configured_port) {
|
||||
snprintf(startup_msg, sizeof(startup_msg),
|
||||
@@ -2271,11 +2655,30 @@ int start_websocket_relay(int port_override, int strict_port) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check connection age limits (every 60 seconds)
|
||||
// Check connection age limits and run IP ban maintenance (every 60 seconds)
|
||||
int max_connection_seconds = get_config_int("max_connection_seconds", 86400);
|
||||
if (max_connection_seconds > 0 && (current_time - last_connection_age_check >= 60)) {
|
||||
if (current_time - last_connection_age_check >= 60) {
|
||||
last_connection_age_check = current_time;
|
||||
check_connection_age(max_connection_seconds);
|
||||
|
||||
// Live debug level update: read from config table so it can be changed
|
||||
// without restarting the relay (via config_set admin command or direct SQL)
|
||||
int config_debug_level = get_config_int("debug_level", -1);
|
||||
if (config_debug_level >= 0 && config_debug_level != g_debug_level) {
|
||||
DEBUG_WARN("Debug level changed: %d -> %d", g_debug_level, config_debug_level);
|
||||
g_debug_level = config_debug_level;
|
||||
}
|
||||
// Check and close idle connections (no REQ/EVENT sent within timeout)
|
||||
int idle_timeout_sec = get_config_int("idle_connection_timeout_sec", 30);
|
||||
check_idle_connections(idle_timeout_sec);
|
||||
|
||||
if (max_connection_seconds > 0) {
|
||||
check_connection_age(max_connection_seconds);
|
||||
} else {
|
||||
// Even when connection age limit is disabled, run IP ban maintenance
|
||||
ip_ban_cleanup();
|
||||
extern sqlite3* g_db;
|
||||
ip_ban_log_stats(g_db);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2294,7 +2697,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;
|
||||
@@ -2323,6 +2726,7 @@ int process_dm_stats_command(cJSON* dm_event, char* error_message, size_t error_
|
||||
}
|
||||
}
|
||||
}
|
||||
free((char*)relay_pubkey);
|
||||
|
||||
if (!addressed_to_relay) {
|
||||
// Not addressed to relay, allow normal processing
|
||||
@@ -2330,7 +2734,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;
|
||||
@@ -2341,9 +2745,11 @@ int process_dm_stats_command(cJSON* dm_event, char* error_message, size_t error_
|
||||
const char* admin_pubkey = get_config_value("admin_pubkey");
|
||||
if (!admin_pubkey || strlen(admin_pubkey) == 0 ||
|
||||
strcmp(sender_pubkey, admin_pubkey) != 0) {
|
||||
if (admin_pubkey) free((char*)admin_pubkey);
|
||||
strncpy(error_message, "Unauthorized: not admin", error_size - 1);
|
||||
return -1;
|
||||
}
|
||||
free((char*)admin_pubkey);
|
||||
|
||||
// Get relay private key for decryption
|
||||
char* relay_privkey_hex = get_relay_private_key();
|
||||
@@ -2369,7 +2775,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 +2901,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 +2929,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 +2973,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 +3075,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 +3103,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 +3111,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
|
||||
@@ -84,8 +89,19 @@ struct per_session_data {
|
||||
int db_queries_executed; // Total SELECT queries executed by this connection
|
||||
int db_rows_returned; // Total rows returned across all queries
|
||||
time_t query_tracking_start; // When connection was established (for rate calculation)
|
||||
|
||||
// Session activity tracking for idle connection banning
|
||||
int session_active; // 1 if client sent REQ or EVENT, 0 otherwise
|
||||
int idle_timeout_sec; // Timeout value for this session (copied from config)
|
||||
int is_websocket; // 1 if this is a WebSocket connection, 0 for HTTP
|
||||
};
|
||||
|
||||
// Get current active WebSocket connection count
|
||||
int get_active_connection_count(void);
|
||||
|
||||
// Actual relay port bound by libwebsockets at runtime (after fallback/retry logic)
|
||||
extern int g_relay_port;
|
||||
|
||||
// NIP-11 HTTP session data structure for managing buffer lifetime
|
||||
struct nip11_session_data {
|
||||
int type; // 0 for NIP-11
|
||||
@@ -102,6 +118,10 @@ int start_websocket_relay(int port_override, int strict_port);
|
||||
int queue_message(struct lws* wsi, struct per_session_data* pss, const char* message, size_t length, enum lws_write_protocol type);
|
||||
int process_message_queue(struct lws* wsi, struct per_session_data* pss);
|
||||
|
||||
// Zero-copy variant: caller allocates (LWS_PRE + length) bytes, writes message at buf+LWS_PRE,
|
||||
// then passes ownership to the queue. The queue will free buf when done. No memcpy performed.
|
||||
int queue_message_take_ownership(struct lws* wsi, struct per_session_data* pss, unsigned char* buf, size_t length, enum lws_write_protocol type);
|
||||
|
||||
// Auth rules checking function from request_validator.c
|
||||
int check_database_auth_rules(const char *pubkey, const char *operation, const char *resource_hash);
|
||||
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import WebSocket from 'ws';
|
||||
import {
|
||||
finalizeEvent,
|
||||
generateSecretKey,
|
||||
getPublicKey,
|
||||
nip19,
|
||||
nip42,
|
||||
} from 'nostr-tools';
|
||||
|
||||
const RELAY_URL = process.env.RELAY_URL || 'ws://127.0.0.1:7777';
|
||||
const EVENT_COUNT = Number(process.env.EVENT_COUNT || 5);
|
||||
const DELAY_MS = Number(process.env.DELAY_MS || 2000);
|
||||
const RECIPIENT_PUBKEY_ENV = process.env.RECIPIENT_PUBKEY || '';
|
||||
const SECRET_INPUT = process.env.NOSTR_SECRET_KEY || '';
|
||||
const OVERALL_TIMEOUT_MS = Number(process.env.OVERALL_TIMEOUT_MS || 120000);
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function log(msg, extra = null) {
|
||||
if (extra !== null) {
|
||||
console.log(`[${now()}] ${msg}`, extra);
|
||||
} else {
|
||||
console.log(`[${now()}] ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHexSecret(secretInput) {
|
||||
if (!secretInput) return null;
|
||||
|
||||
const raw = secretInput.trim();
|
||||
if (/^[0-9a-fA-F]{64}$/.test(raw)) {
|
||||
return raw.toLowerCase();
|
||||
}
|
||||
|
||||
if (raw.startsWith('nsec1')) {
|
||||
const decoded = nip19.decode(raw);
|
||||
if (decoded.type !== 'nsec') {
|
||||
throw new Error('NOSTR_SECRET_KEY is nsec-like but did not decode as nsec');
|
||||
}
|
||||
if (typeof decoded.data === 'string') {
|
||||
return decoded.data.toLowerCase();
|
||||
}
|
||||
if (decoded.data instanceof Uint8Array) {
|
||||
return Buffer.from(decoded.data).toString('hex').toLowerCase();
|
||||
}
|
||||
throw new Error('Unsupported nsec decoded payload type');
|
||||
}
|
||||
|
||||
throw new Error('NOSTR_SECRET_KEY must be 64-char hex or nsec1...');
|
||||
}
|
||||
|
||||
const hexSecret = normalizeHexSecret(SECRET_INPUT) || Buffer.from(generateSecretKey()).toString('hex');
|
||||
const secretKey = Buffer.from(hexSecret, 'hex');
|
||||
const senderPubkey = getPublicKey(secretKey);
|
||||
const recipientPubkey = RECIPIENT_PUBKEY_ENV || senderPubkey;
|
||||
|
||||
let ws;
|
||||
let currentChallenge = null;
|
||||
let authenticated = false;
|
||||
let authAttempts = 0;
|
||||
let sentAccepted = 0;
|
||||
let nextSeq = 1;
|
||||
let pendingEvent = null;
|
||||
let waitingForAuth = false;
|
||||
let finished = false;
|
||||
let overallTimer = null;
|
||||
|
||||
function makeKind4Event(seq) {
|
||||
const eventTemplate = {
|
||||
kind: 4,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['p', recipientPubkey]],
|
||||
content: `kind4 disconnect probe seq=${seq} ts=${Date.now()}`,
|
||||
pubkey: senderPubkey,
|
||||
};
|
||||
return finalizeEvent(eventTemplate, secretKey);
|
||||
}
|
||||
|
||||
function sendJson(arr) {
|
||||
const payload = JSON.stringify(arr);
|
||||
ws.send(payload);
|
||||
}
|
||||
|
||||
function sendAuth(challenge) {
|
||||
const authTemplate = nip42.makeAuthEvent(RELAY_URL, challenge);
|
||||
const signedAuth = finalizeEvent(authTemplate, secretKey);
|
||||
authAttempts += 1;
|
||||
log(`Sending AUTH attempt #${authAttempts} with challenge ${challenge}`);
|
||||
sendJson(['AUTH', signedAuth]);
|
||||
}
|
||||
|
||||
function scheduleNext() {
|
||||
if (nextSeq > EVENT_COUNT) {
|
||||
log(`All ${EVENT_COUNT} kind-4 events were accepted without disconnect`);
|
||||
cleanupAndExit(0);
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (!finished) {
|
||||
publishNext();
|
||||
}
|
||||
}, DELAY_MS);
|
||||
}
|
||||
|
||||
function publishNext() {
|
||||
if (waitingForAuth) {
|
||||
log('Still waiting for auth completion, postponing publish');
|
||||
return;
|
||||
}
|
||||
|
||||
const ev = makeKind4Event(nextSeq);
|
||||
pendingEvent = ev;
|
||||
log(`Publishing kind-4 seq=${nextSeq} id=${ev.id.slice(0, 12)}...`);
|
||||
sendJson(['EVENT', ev]);
|
||||
}
|
||||
|
||||
function retryPendingAfterAuth() {
|
||||
if (!pendingEvent) return;
|
||||
waitingForAuth = false;
|
||||
log(`Retrying pending kind-4 id=${pendingEvent.id.slice(0, 12)}... after auth`);
|
||||
sendJson(['EVENT', pendingEvent]);
|
||||
}
|
||||
|
||||
function handleAuthRequiredSignal(source, msg) {
|
||||
waitingForAuth = true;
|
||||
log(`Relay signaled auth-required via ${source}: ${msg}`);
|
||||
if (currentChallenge) {
|
||||
sendAuth(currentChallenge);
|
||||
} else {
|
||||
log('No AUTH challenge received yet; waiting for relay AUTH challenge message');
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupAndExit(code) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
if (overallTimer) clearTimeout(overallTimer);
|
||||
|
||||
const summary = {
|
||||
relay: RELAY_URL,
|
||||
sentAccepted,
|
||||
expected: EVENT_COUNT,
|
||||
authAttempts,
|
||||
authenticated,
|
||||
senderPubkey,
|
||||
recipientPubkey,
|
||||
};
|
||||
|
||||
if (code === 0) {
|
||||
log('PASS: Connection remained stable through repeated kind-4 publish cycle', summary);
|
||||
} else {
|
||||
log('FAIL: Relay disconnected or test did not complete successfully', summary);
|
||||
}
|
||||
|
||||
try {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function main() {
|
||||
log('Starting kind-4 disconnect probe', {
|
||||
relay: RELAY_URL,
|
||||
eventCount: EVENT_COUNT,
|
||||
delayMs: DELAY_MS,
|
||||
senderPubkey,
|
||||
recipientPubkey,
|
||||
});
|
||||
|
||||
ws = new WebSocket(RELAY_URL);
|
||||
|
||||
overallTimer = setTimeout(() => {
|
||||
log(`Overall timeout reached (${OVERALL_TIMEOUT_MS}ms)`);
|
||||
cleanupAndExit(1);
|
||||
}, OVERALL_TIMEOUT_MS);
|
||||
|
||||
ws.on('open', () => {
|
||||
log('WebSocket connected');
|
||||
publishNext();
|
||||
});
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
const text = raw.toString();
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(text);
|
||||
} catch {
|
||||
log(`Non-JSON relay message: ${text}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const t = msg?.[0];
|
||||
|
||||
if (t === 'AUTH') {
|
||||
currentChallenge = msg?.[1] || null;
|
||||
log(`Received AUTH challenge: ${currentChallenge}`);
|
||||
if (currentChallenge) sendAuth(currentChallenge);
|
||||
return;
|
||||
}
|
||||
|
||||
if (t === 'NOTICE') {
|
||||
const notice = String(msg?.[1] || '');
|
||||
log(`NOTICE: ${notice}`);
|
||||
|
||||
if (notice.toLowerCase().includes('auth-required')) {
|
||||
handleAuthRequiredSignal('NOTICE', notice);
|
||||
return;
|
||||
}
|
||||
|
||||
if (notice.toLowerCase().includes('authentication successful')) {
|
||||
authenticated = true;
|
||||
log('Relay confirmed NIP-42 authentication success');
|
||||
retryPendingAfterAuth();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (t === 'OK') {
|
||||
const eventId = msg?.[1];
|
||||
const ok = !!msg?.[2];
|
||||
const reason = String(msg?.[3] || '');
|
||||
log(`OK for ${String(eventId).slice(0, 12)}... accepted=${ok} reason=${reason}`);
|
||||
|
||||
if (!ok && reason.toLowerCase().includes('auth-required')) {
|
||||
handleAuthRequiredSignal('OK', reason);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingEvent && eventId === pendingEvent.id) {
|
||||
if (ok) {
|
||||
sentAccepted += 1;
|
||||
nextSeq += 1;
|
||||
pendingEvent = null;
|
||||
waitingForAuth = false;
|
||||
scheduleNext();
|
||||
} else {
|
||||
log(`Pending event rejected: ${reason}`);
|
||||
cleanupAndExit(1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
log('Relay message', msg);
|
||||
});
|
||||
|
||||
ws.on('close', (code, reasonBuf) => {
|
||||
const reason = reasonBuf?.toString?.() || '';
|
||||
log(`WebSocket closed code=${code} reason=${reason}`);
|
||||
if (!finished) {
|
||||
if (sentAccepted >= EVENT_COUNT) {
|
||||
cleanupAndExit(0);
|
||||
} else {
|
||||
cleanupAndExit(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
log(`WebSocket error: ${err.message}`);
|
||||
cleanupAndExit(1);
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,88 @@
|
||||
=== NIP-42 Authentication Test Started ===
|
||||
2026-02-24 09:45:30 - Starting NIP-42 authentication tests
|
||||
[34m[1m[INFO][0m === Starting NIP-42 Authentication Tests ===
|
||||
[34m[1m[INFO][0m Checking dependencies...
|
||||
[32m[1m[SUCCESS][0m Dependencies check complete
|
||||
[34m[1m[INFO][0m Test 1: Checking NIP-42 support in relay info
|
||||
[32m[1m[SUCCESS][0m NIP-42 is advertised in supported NIPs
|
||||
2026-02-24 09:45:30 - Supported NIPs: 1,2,4,9,11,12,13,15,16,20,22,33,40,42,50,70
|
||||
[34m[1m[INFO][0m Test 2: Testing AUTH challenge generation
|
||||
[33m[1m[WARNING][0m Could not extract admin private key from relay.log - using manual test approach
|
||||
[34m[1m[INFO][0m Manual test: Connect to relay and send an event without auth to trigger challenge
|
||||
[34m[1m[INFO][0m Test 3: Testing complete NIP-42 authentication flow
|
||||
[34m[1m[INFO][0m Generated test keypair: test_pubkey
|
||||
[34m[1m[INFO][0m Attempting to publish event without authentication...
|
||||
[34m[1m[INFO][0m Publishing test event to relay...
|
||||
2026-02-24 09:45:31 - Event publish result: connecting to ws://localhost:8888... ok.
|
||||
{"kind":1,"id":"74a0b723797569b2483d47457d2f6e2378aed6ccd4cca0f553038e0431ade0e8","pubkey":"7a2f213c220c46a194c5730e5bb2fe9b27304f0c27226f380086d93ff10bf7da","created_at":1771940731,"tags":[],"content":"NIP-42 test event - should require auth","sig":"319466df2167cd7f33c83d41b09bdd06f23c961380fd205a0a5dc5a923f11e45b6d158d79e251239bef364430c5d45e6a9e43fe93fd629864a88de00e6766a44"}
|
||||
publishing to ws://localhost:8888... success.
|
||||
[32m[1m[SUCCESS][0m Relay requested authentication as expected
|
||||
[34m[1m[INFO][0m Test 4: Testing WebSocket AUTH message handling
|
||||
[34m[1m[INFO][0m Testing WebSocket connection and AUTH message...
|
||||
[34m[1m[INFO][0m Sending test message via WebSocket...
|
||||
2026-02-24 09:45:31 - WebSocket response:
|
||||
[34m[1m[INFO][0m No AUTH challenge in WebSocket response
|
||||
[34m[1m[INFO][0m Test 5: Testing NIP-42 configuration options
|
||||
[34m[1m[INFO][0m Retrieving current relay configuration...
|
||||
[33m[1m[WARNING][0m Could not retrieve configuration events
|
||||
[34m[1m[INFO][0m Test 6: Testing NIP-42 performance and stability
|
||||
[34m[1m[INFO][0m Testing multiple authentication attempts...
|
||||
2026-02-24 09:45:33 - Attempt 1: .264126491s - connecting to ws://localhost:8888... ok.
|
||||
{"kind":1,"id":"9cb8f626ced97e8fc406bd2d2358074fe33542d983d11a7f039a26919ded6039","pubkey":"7c098dbaeeaca6fb27798625835dfc5c13fb31096eed76c6d77269a2a08cd22b","created_at":1771940733,"tags":[],"content":"Performance test event 1","sig":"87025c881004a7936589785bbba6171542135348e4f798c490c033eb462c9a0322882cfe1a2b742e3644b6c69ee4fee76feb3cffecd6d64c096fa8038bcd84c4"}
|
||||
publishing to ws://localhost:8888... success.
|
||||
2026-02-24 09:45:33 - Attempt 2: .262980094s - connecting to ws://localhost:8888... ok.
|
||||
{"kind":1,"id":"bb1b3da4cccbcdc6be73646e29ecf060b68d8cb9340f8b23da0f167ffac0831d","pubkey":"7c098dbaeeaca6fb27798625835dfc5c13fb31096eed76c6d77269a2a08cd22b","created_at":1771940733,"tags":[],"content":"Performance test event 2","sig":"1349f7af97edb9f507081782cfb2d055eb810ec96056b97692920060091ccf39b8d1ba8590d25e1b3c92f4b785f6362ac0d734373d29d4159e5778f8982b086f"}
|
||||
publishing to ws://localhost:8888... success.
|
||||
2026-02-24 09:45:34 - Attempt 3: .296311100s - connecting to ws://localhost:8888... ok.
|
||||
{"kind":1,"id":"3b171de7c00e918ad0bcbedea4c07c69f59a9d20c31b0de957488c919fa5c116","pubkey":"7c098dbaeeaca6fb27798625835dfc5c13fb31096eed76c6d77269a2a08cd22b","created_at":1771940734,"tags":[],"content":"Performance test event 3","sig":"a1699f12064d4da7ba15519eb7666cd2801f333ee837df70ce1298ac97477e900d095dce8fed0bade8f3e3e2e2b68e22800d10ba213a1de6cd1537112d6a6c2a"}
|
||||
publishing to ws://localhost:8888... success.
|
||||
2026-02-24 09:45:35 - Attempt 4: .300140904s - connecting to ws://localhost:8888... ok.
|
||||
{"kind":1,"id":"f3587b9955204284ec65f2b171d1fa3f330e0bbfc2d95fe7ad0550a63a6aabe6","pubkey":"7c098dbaeeaca6fb27798625835dfc5c13fb31096eed76c6d77269a2a08cd22b","created_at":1771940734,"tags":[],"content":"Performance test event 4","sig":"fb78cd3bce49097eea1c67c0a1ee92bf4cdf5bf6396ce20519481d0e4d09b03dca05c70da2ddf5ba51d23aa3253b21ec3b57bc41d000f274bb1123a4c3d64c45"}
|
||||
publishing to ws://localhost:8888... success.
|
||||
2026-02-24 09:45:35 - Attempt 5: .373738147s - connecting to ws://localhost:8888... ok.
|
||||
{"kind":1,"id":"e1fdf2d0579f0e2b446bdd9dbe5f5e53913b7d328d6f527ac7142636979c1ff2","pubkey":"7c098dbaeeaca6fb27798625835dfc5c13fb31096eed76c6d77269a2a08cd22b","created_at":1771940735,"tags":[],"content":"Performance test event 5","sig":"a11a11991434511737092d8cf2806a700a207c81c08e341f8d98c8b3369845df35adb9247ff094378e6712d68e5b2657d628e809b17bc9f838f6f5d9c21ce96c"}
|
||||
publishing to ws://localhost:8888... success.
|
||||
[32m[1m[SUCCESS][0m Performance test completed: 5/5 successful responses
|
||||
[34m[1m[INFO][0m Test 7: Testing kind-specific NIP-42 authentication requirements
|
||||
[34m[1m[INFO][0m Generated test keypair for kind-specific tests: test_pubkey
|
||||
[34m[1m[INFO][0m Testing kind 1 event (regular note) - should work without authentication...
|
||||
2026-02-24 09:45:36 - Kind 1 event result: connecting to ws://localhost:8888... ok.
|
||||
{"kind":1,"id":"85430aa1bb17e0aa7f5f05568448e35570a0d8446a79600f033e2683b17f8902","pubkey":"1cee941c80f1fa0037047151cf577503a1243cbef748f572b9fd6062a36807db","created_at":1771940736,"tags":[],"content":"Regular note - should not require auth","sig":"118a2dec1256aca39b22b9027c8f56e0b8f60bae1d95f38408ac9d9a6eb6590c48e4a37bf561e29f993b435ceecc1ff2218fcc9dec3e2dcf305cd456ac6a0ee4"}
|
||||
publishing to ws://localhost:8888... success.
|
||||
[32m[1m[SUCCESS][0m Kind 1 event accepted without authentication (correct behavior)
|
||||
[34m[1m[INFO][0m Testing kind 4 event (direct message) - should require authentication...
|
||||
2026-02-24 09:45:47 - Kind 4 event result: connecting to ws://localhost:8888... ok.
|
||||
{"kind":4,"id":"32f20767dd83d6f1ee5e70f72c33c7caa8a5a03acbfbb4de5899f6b27b850be4","pubkey":"1cee941c80f1fa0037047151cf577503a1243cbef748f572b9fd6062a36807db","created_at":1771940737,"tags":[["p,test_pubkey"]],"content":"This is a direct message - should require auth","sig":"cee96adf8c266120d98579734134cca652eeefa5f090d119b794a964bae4d2568e3aa39d648e5a897bb7f8d65be19f338291af3ffe87accdbf502f5eaae453ab"}
|
||||
publishing to ws://localhost:8888...
|
||||
[32m[1m[SUCCESS][0m Kind 4 event requested authentication (correct behavior for DMs)
|
||||
[34m[1m[INFO][0m Testing kind 14 event (chat message) - should require authentication...
|
||||
2026-02-24 09:45:57 - Kind 14 event result: connecting to ws://localhost:8888... ok.
|
||||
{"kind":14,"id":"0a6d37f32fc10dae793b61d8f3afb9d28bdf12d59cf25d73eb90461d8efaa117","pubkey":"1cee941c80f1fa0037047151cf577503a1243cbef748f572b9fd6062a36807db","created_at":1771940747,"tags":[["p,test_pubkey"]],"content":"Chat message - should require auth","sig":"5337ded22499a8c361445d6c660f297e636cc5adf2735a85bb7d629e5771e0c47176b2afde02f4af9645198d441fb0aedd78931d0200d8b5ed8f721e6460d5c9"}
|
||||
publishing to ws://localhost:8888...
|
||||
[32m[1m[SUCCESS][0m Kind 14 event requested authentication (correct behavior for DMs)
|
||||
[34m[1m[INFO][0m Testing other event kinds - should work without authentication...
|
||||
2026-02-24 09:45:58 - Kind 0 event result: connecting to ws://localhost:8888... ok.
|
||||
{"kind":0,"id":"5836e2da48eb458c8faca084afc3827ee6e481574d0295dc7bfdc50d9495891a","pubkey":"1cee941c80f1fa0037047151cf577503a1243cbef748f572b9fd6062a36807db","created_at":1771940757,"tags":[],"content":"Test event kind 0 - should not require auth","sig":"e454d5990a157b6211b655a9219ad494c58d49a59d61c4e1fb0aa91b96e5ff2368efc57e2acd0a24c7198b1181c37d0ab535f608289ec83afdc0b68e076def7c"}
|
||||
publishing to ws://localhost:8888... success.
|
||||
[32m[1m[SUCCESS][0m Kind 0 event accepted without authentication (correct)
|
||||
2026-02-24 09:45:58 - Kind 3 event result: connecting to ws://localhost:8888... ok.
|
||||
{"kind":3,"id":"d11a038044f9ea859338c6ab879c4628aece258f2cad0ed7ca88808b35ec899c","pubkey":"1cee941c80f1fa0037047151cf577503a1243cbef748f572b9fd6062a36807db","created_at":1771940758,"tags":[],"content":"Test event kind 3 - should not require auth","sig":"9fbf410cd609f9f7a6bf3118ecfe554776bb647d5ed988c0d4df06f83c1283c8baeaa0b62da900934cc8f617c73572fbce9f7232c77bb87942e4471228b06c73"}
|
||||
publishing to ws://localhost:8888... success.
|
||||
[32m[1m[SUCCESS][0m Kind 3 event accepted without authentication (correct)
|
||||
2026-02-24 09:45:59 - Kind 7 event result: connecting to ws://localhost:8888... ok.
|
||||
{"kind":7,"id":"b819482f287f6370fac7804c2cdcdac8340eafd86799b24af0a11f74c6190fed","pubkey":"1cee941c80f1fa0037047151cf577503a1243cbef748f572b9fd6062a36807db","created_at":1771940758,"tags":[],"content":"Test event kind 7 - should not require auth","sig":"f197c7c072af111dd92220c489472473f5b4fb30e908501e4d2d0037f1a0064960550a4e51af4168572a192295d870f6a6c49473308a25dacbc0deb703003547"}
|
||||
publishing to ws://localhost:8888... success.
|
||||
[32m[1m[SUCCESS][0m Kind 7 event accepted without authentication (correct)
|
||||
[34m[1m[INFO][0m Kind-specific authentication test completed
|
||||
[34m[1m[INFO][0m === NIP-42 Test Results Summary ===
|
||||
[32m[1m[SUCCESS][0m Dependencies: PASS
|
||||
[32m[1m[SUCCESS][0m NIP-42 Support: PASS
|
||||
[32m[1m[SUCCESS][0m Auth Challenge: PASS
|
||||
[32m[1m[SUCCESS][0m Auth Flow: PASS
|
||||
[32m[1m[SUCCESS][0m WebSocket AUTH: PASS
|
||||
[32m[1m[SUCCESS][0m Configuration: PASS
|
||||
[32m[1m[SUCCESS][0m Performance: PASS
|
||||
[32m[1m[SUCCESS][0m Kind-Specific Auth: PASS
|
||||
[32m[1m[SUCCESS][0m All NIP-42 tests completed successfully!
|
||||
[32m[1m[SUCCESS][0m NIP-42 authentication implementation is working correctly
|
||||
[34m[1m[INFO][0m === NIP-42 Authentication Tests Complete ===
|
||||
Reference in New Issue
Block a user