Compare commits

...
15 Commits
Author SHA1 Message Date
Your Name 0920cc092d v1.2.55 - Preserve relay version metadata for API header/title and accept JSON NIP-11 content type 2026-03-23 09:09:50 -04:00
Your Name a89460be5d v1.2.54 - Sync relay_version to compiled CRELAY_VERSION on startup and show version in API header/title 2026-03-23 09:04:49 -04:00
Your Name ff2a3aa335 v1.2.53 - Fix P0 config-value memory leaks in hot paths and add investigation plan 2026-03-19 07:59:40 -04:00
Your Name 94b61e8a7c v1.2.52 - Update nostr_core_lib to v0.4.13 and route nostr logs through relay callback logger 2026-03-13 14:33:08 -04:00
Your Name b0c0754e83 v1.2.51 - Fix NIP-01 non-compliance: limit:0 now correctly returns zero stored events instead of falling through to default_limit 2026-03-04 11:23:58 -04:00
Your Name 3265e3d114 v1.2.50 - Fix NIP-42 relay URL verification and add onauth to all publish flows 2026-03-01 12:20:44 -04:00
Your Name 927659ece1 v1.2.49 - Fix: MEM% bar format (bar then MB), move CPU Core row after CPU Usage 2026-02-25 07:37:50 -04:00
Your Name b6ff4150b4 v1.2.48 - Add CPU% and MEM% ASCII bar graphs to API page stats 2026-02-25 07:33:06 -04:00
Your Name 63bc526163 v1.2.47 - Fix build: use getter function for g_connection_count (was static, can't extern) 2026-02-25 07:24:17 -04:00
Your Name a1f712236a v1.2.46 - Fix: move extern g_connection_count to file scope in api.c 2026-02-25 07:22:14 -04:00
Your Name 06e6c17b7b v1.2.45 - Add WebSocket Connections to system status: api.c sends active_connections, HTML+JS display it 2026-02-25 07:18:42 -04:00
Your Name 0751a7c55c v1.2.44 - IP Bans: show idle+auth bans in stats/status, fix filter buttons, add idle ban columns to query 2026-02-25 07:12:07 -04:00
Your Name f1728932a9 v1.2.43 - IP Bans page: fix filter buttons, add whitelist management UI, compact table rows 2026-02-25 07:08:20 -04:00
Your Name ef8bdef2a8 v1.2.42 - IP Bans page: fix filter buttons, add whitelist management UI, compact table rows 2026-02-25 07:08:12 -04:00
Your Name c11a8ba292 v1.2.41 - Fix IP Bans page: route ip_bans SQL responses to handleIpBansResponse 2026-02-25 06:59:22 -04:00
21 changed files with 1239 additions and 186 deletions
+13
View File
@@ -1486,3 +1486,16 @@ body.dark-mode .admin-verification-content {
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;
}
+23 -4
View File
@@ -112,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>
@@ -120,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>
@@ -435,6 +439,21 @@ WEB OF TRUST
<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">
+456 -156
View File
File diff suppressed because it is too large Load Diff
+95 -1
View File
@@ -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) {
+1
View File
@@ -0,0 +1 @@
key,value,data_type,description,category,requires_restart,created_at,updated_at
1 key value data_type description category requires_restart created_at updated_at
+207
View File
@@ -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 |
+1 -1
View File
@@ -1 +1 @@
2375515
3134018
+25 -2
View File
@@ -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>
@@ -1291,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);
@@ -1308,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;
}
+7 -4
View File
@@ -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
}
+2 -2
View File
@@ -101,8 +101,8 @@ static const struct {
// Idle Connection Ban Settings
// Ban IPs that connect but never send REQ or EVENT (idle or early disconnect)
{"idle_connection_timeout_sec", "30"}, // Seconds before idle connection is closed (0 = disabled)
{"idle_ban_enabled", "true"}, // Whether to ban IPs with idle failures
{"idle_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)
File diff suppressed because one or more lines are too long
+12 -3
View File
@@ -253,23 +253,32 @@ void ip_ban_save_to_db(sqlite3* db) {
// 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') return 0;
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) return 1;
if (strcmp(token, ip) == 0) {
is_match = 1;
break;
}
token = strtok(NULL, ",");
}
return 0;
free((char*)whitelist);
return is_match;
}
int ip_ban_is_banned(const char* ip) {
+82 -1
View File
@@ -186,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);
@@ -1260,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;
@@ -1650,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)) {
@@ -1661,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;
@@ -1669,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
@@ -1689,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;
}
@@ -1701,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;
}
@@ -1709,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;
@@ -1920,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");
@@ -2068,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");
@@ -2185,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
View File
@@ -13,8 +13,8 @@
// Using CRELAY_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define CRELAY_VERSION_MAJOR 1
#define CRELAY_VERSION_MINOR 2
#define CRELAY_VERSION_PATCH 40
#define CRELAY_VERSION "v1.2.40"
#define CRELAY_VERSION_PATCH 55
#define CRELAY_VERSION "v1.2.55"
// Relay metadata (authoritative source for NIP-11 information)
#define RELAY_NAME "C-Relay"
+11 -1
View File
@@ -12,6 +12,7 @@
#include <string.h>
#include <stdlib.h>
#include <time.h>
#include <stdio.h>
#include "websockets.h"
#include "ip_ban.h"
@@ -94,8 +95,17 @@ 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) {
+2
View File
@@ -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
}
+13
View File
@@ -116,6 +116,9 @@ 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
@@ -130,6 +133,8 @@ 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++) {
@@ -1535,6 +1540,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
}
}
}
free((char*)relay_pubkey);
}
}
}
@@ -1547,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) {
@@ -2607,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),
@@ -2716,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
@@ -2734,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();
+6
View File
@@ -96,6 +96,12 @@ struct per_session_data {
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
+272
View File
@@ -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();