Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c077c209e5 | ||
|
|
9bf02702c2 | ||
|
|
0920cc092d | ||
|
|
a89460be5d | ||
|
|
ff2a3aa335 | ||
|
|
94b61e8a7c | ||
|
|
b0c0754e83 | ||
|
|
3265e3d114 | ||
|
|
927659ece1 | ||
|
|
b6ff4150b4 | ||
|
|
63bc526163 | ||
|
|
a1f712236a | ||
|
|
06e6c17b7b | ||
|
|
0751a7c55c | ||
|
|
f1728932a9 | ||
|
|
ef8bdef2a8 | ||
|
|
c11a8ba292 | ||
|
|
0f124fe575 |
@@ -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 src/ip_ban.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 src/db_ops.c src/thread_pool.c
|
||||
NOSTR_CORE_LIB = nostr_core_lib/libnostr_core_x64.a
|
||||
C_UTILS_LIB = c_utils_lib/libc_utils.a
|
||||
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
+462
-157
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) {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
key,value,data_type,description,category,requires_restart,created_at,updated_at
|
||||
|
+1
-1
Submodule nostr_core_lib updated: a8dc2ed046...98cfd81b01
@@ -0,0 +1,632 @@
|
||||
# c-relay-pg Plan — PostgreSQL Backend + Multi-Instance + Dashboard
|
||||
|
||||
## Overview
|
||||
|
||||
**c-relay-pg** is a new project (separate repository) forked from c-relay after the `db_ops` abstraction layer is complete. It replaces the SQLite backend with PostgreSQL, enabling horizontal scaling, external dashboards, and production-grade deployments.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- The `db_ops.h` / `db_ops.c` abstraction layer must be complete in c-relay first — see [c-relay thread pool plan](c_relay_thread_pool_plan.md) Phase 1.
|
||||
- The fork happens after Phase 1 of that plan is done and tested.
|
||||
|
||||
### Why a Separate Project?
|
||||
|
||||
| | c-relay | c-relay-pg |
|
||||
|---|---------|-----------|
|
||||
| **Database** | SQLite (embedded) | PostgreSQL (client-server) |
|
||||
| **Deployment** | Single binary + DB file | Binary + PostgreSQL server |
|
||||
| **Scaling** | Single instance | Multiple instances + load balancer |
|
||||
| **Dashboard** | Embedded web UI | Separate web server + Grafana |
|
||||
| **Target user** | Personal relay, small community | Medium-large relay, production |
|
||||
| **Complexity** | Minimal | More infrastructure |
|
||||
|
||||
For the full analysis of why PostgreSQL was chosen over MySQL/MariaDB and other databases, see the [database architecture analysis](database_architecture_analysis.md).
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph c-relay-pg - Production Deployment
|
||||
LB[nginx - TLS + load balancing] -->|WebSocket| R1[c-relay-pg Instance 1]
|
||||
LB -->|WebSocket| R2[c-relay-pg Instance 2]
|
||||
LB -->|HTTP| DASH[Dashboard]
|
||||
R1 -->|db_ops API| PGBACK[PostgreSQL Backend - db_ops_postgres.c]
|
||||
R2 -->|db_ops API| PGBACK
|
||||
PGBACK -->|libpq| PG[PostgreSQL Server]
|
||||
DASH -->|SQL| PG
|
||||
R1 <-->|LISTEN/NOTIFY| R2
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: PostgreSQL Backend
|
||||
|
||||
### Goal
|
||||
|
||||
Fork c-relay into a new repository called **c-relay-pg**. Replace the SQLite `db_ops` implementation with PostgreSQL using `libpq`. The `db_ops.h` interface stays identical — only the backend changes.
|
||||
|
||||
### New Files
|
||||
|
||||
- `src/db_ops_sqlite.c` — Renamed from `db_ops.c` (the Phase 1 SQLite implementation from c-relay)
|
||||
- `src/db_ops_postgres.c` — New PostgreSQL implementation
|
||||
- `src/db_ops.c` — Thin dispatcher that calls the active backend
|
||||
|
||||
### PostgreSQL Schema
|
||||
|
||||
```sql
|
||||
-- PostgreSQL schema for c-relay-pg
|
||||
-- No event_tags table needed — JSONB + GIN handles everything
|
||||
|
||||
CREATE TABLE events (
|
||||
id TEXT PRIMARY KEY,
|
||||
pubkey TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
kind INTEGER NOT NULL,
|
||||
event_type TEXT NOT NULL CHECK (event_type IN ('regular', 'replaceable', 'ephemeral', 'addressable')),
|
||||
content TEXT NOT NULL,
|
||||
sig TEXT NOT NULL,
|
||||
tags JSONB NOT NULL DEFAULT '[]',
|
||||
event_json TEXT NOT NULL,
|
||||
first_seen BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT
|
||||
);
|
||||
|
||||
-- Core indexes
|
||||
CREATE INDEX idx_events_pubkey ON events(pubkey);
|
||||
CREATE INDEX idx_events_kind ON events(kind);
|
||||
CREATE INDEX idx_events_created_at ON events(created_at DESC);
|
||||
CREATE INDEX idx_events_kind_created_at ON events(kind, created_at DESC);
|
||||
CREATE INDEX idx_events_pubkey_created_at ON events(pubkey, created_at DESC);
|
||||
CREATE INDEX idx_events_pubkey_kind ON events(pubkey, kind);
|
||||
|
||||
-- THE KEY INDEX: GIN on JSONB tags — replaces the entire event_tags table
|
||||
CREATE INDEX idx_events_tags ON events USING GIN (tags);
|
||||
|
||||
-- Partial index: only non-ephemeral events (what REQ queries actually filter)
|
||||
CREATE INDEX idx_events_non_ephemeral ON events(created_at DESC)
|
||||
WHERE kind < 20000 OR kind >= 30000;
|
||||
|
||||
-- Config table
|
||||
CREATE TABLE config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
data_type TEXT NOT NULL CHECK (data_type IN ('string', 'integer', 'boolean', 'json')),
|
||||
description TEXT,
|
||||
category TEXT DEFAULT 'general',
|
||||
requires_restart INTEGER DEFAULT 0,
|
||||
created_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
|
||||
updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT
|
||||
);
|
||||
|
||||
-- Auth rules table
|
||||
CREATE TABLE auth_rules (
|
||||
id SERIAL PRIMARY KEY,
|
||||
rule_type TEXT NOT NULL CHECK (rule_type IN ('whitelist', 'blacklist', 'rate_limit', 'auth_required', 'wot_whitelist')),
|
||||
pattern_type TEXT NOT NULL CHECK (pattern_type IN ('pubkey', 'kind', 'ip', 'global')),
|
||||
pattern_value TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
|
||||
updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_auth_rules_lookup ON auth_rules(rule_type, pattern_type, pattern_value) WHERE active = 1;
|
||||
|
||||
-- Relay private key storage
|
||||
CREATE TABLE relay_seckey (
|
||||
private_key_hex TEXT NOT NULL CHECK (length(private_key_hex) = 64),
|
||||
created_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT
|
||||
);
|
||||
|
||||
-- Subscription logging
|
||||
CREATE TABLE subscriptions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
subscription_id TEXT NOT NULL,
|
||||
wsi_pointer TEXT NOT NULL,
|
||||
client_ip TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL CHECK (event_type IN ('created', 'closed', 'expired', 'disconnected')),
|
||||
filter_json TEXT,
|
||||
events_sent INTEGER DEFAULT 0,
|
||||
created_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
|
||||
ended_at BIGINT,
|
||||
duration INTEGER,
|
||||
UNIQUE(subscription_id, wsi_pointer)
|
||||
);
|
||||
|
||||
-- IP ban persistence
|
||||
CREATE TABLE ip_bans (
|
||||
ip TEXT PRIMARY KEY,
|
||||
failure_count INTEGER NOT NULL DEFAULT 0,
|
||||
ban_count INTEGER NOT NULL DEFAULT 0,
|
||||
banned_until BIGINT NOT NULL DEFAULT 0,
|
||||
first_failure BIGINT NOT NULL DEFAULT 0,
|
||||
has_authed_successfully INTEGER NOT NULL DEFAULT 0,
|
||||
last_success_at BIGINT 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 BIGINT NOT NULL DEFAULT 0,
|
||||
idle_failure_count INTEGER NOT NULL DEFAULT 0,
|
||||
idle_ban_count INTEGER NOT NULL DEFAULT 0,
|
||||
idle_banned_until BIGINT NOT NULL DEFAULT 0,
|
||||
idle_first_failure BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Materialized views for dashboard (refreshed periodically)
|
||||
CREATE MATERIALIZED VIEW event_kinds_mv AS
|
||||
SELECT kind, COUNT(*) as count,
|
||||
ROUND(COUNT(*) * 100.0 / NULLIF((SELECT COUNT(*) FROM events), 0), 2) as percentage
|
||||
FROM events GROUP BY kind;
|
||||
|
||||
CREATE MATERIALIZED VIEW time_stats_mv AS
|
||||
SELECT 'total' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys
|
||||
FROM events
|
||||
UNION ALL
|
||||
SELECT '24h', COUNT(*), COUNT(DISTINCT pubkey)
|
||||
FROM events WHERE created_at >= EXTRACT(EPOCH FROM NOW())::BIGINT - 86400
|
||||
UNION ALL
|
||||
SELECT '7d', COUNT(*), COUNT(DISTINCT pubkey)
|
||||
FROM events WHERE created_at >= EXTRACT(EPOCH FROM NOW())::BIGINT - 604800;
|
||||
|
||||
CREATE MATERIALIZED VIEW top_pubkeys_mv AS
|
||||
SELECT pubkey, COUNT(*) as event_count,
|
||||
ROUND(COUNT(*) * 100.0 / NULLIF((SELECT COUNT(*) FROM events), 0), 2) as percentage
|
||||
FROM events GROUP BY pubkey ORDER BY event_count DESC LIMIT 20;
|
||||
|
||||
-- Unique indexes required for CONCURRENTLY refresh
|
||||
CREATE UNIQUE INDEX idx_event_kinds_mv ON event_kinds_mv(kind);
|
||||
CREATE UNIQUE INDEX idx_time_stats_mv ON time_stats_mv(period);
|
||||
CREATE UNIQUE INDEX idx_top_pubkeys_mv ON top_pubkeys_mv(pubkey);
|
||||
```
|
||||
|
||||
### Key Implementation Details
|
||||
|
||||
#### Tag Queries with JSONB
|
||||
|
||||
The biggest win — replacing the `event_tags` subquery with JSONB containment:
|
||||
|
||||
```c
|
||||
// SQLite (current): subquery into event_tags table
|
||||
// AND id IN (SELECT event_id FROM event_tags WHERE tag_name = ? AND tag_value IN (?))
|
||||
|
||||
// PostgreSQL: JSONB containment operator with GIN index
|
||||
// AND tags @> '[["p", "pubkey_hex"]]'
|
||||
|
||||
// For multiple tag values:
|
||||
// AND (tags @> '[["p", "val1"]]' OR tags @> '[["p", "val2"]]')
|
||||
```
|
||||
|
||||
This eliminates the entire `event_tags` table (4 million rows, ~2 GB indexes in the current SQLite schema). The GIN index on JSONB handles everything in ~100–200 MB.
|
||||
|
||||
#### LISTEN/NOTIFY Integration
|
||||
|
||||
For cross-instance event broadcasting:
|
||||
|
||||
```c
|
||||
// In db_ops_postgres.c:
|
||||
int db_notify_new_event(const char* event_id) {
|
||||
char cmd[128];
|
||||
snprintf(cmd, sizeof(cmd), "NOTIFY new_event, '%s'", event_id);
|
||||
PGresult* res = PQexec(g_pg_conn, cmd);
|
||||
PQclear(res);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Called from the lws event loop every iteration:
|
||||
int db_check_notifications(char* event_id_out, size_t max_len) {
|
||||
PQconsumeInput(g_pg_notify_conn);
|
||||
PGnotify* notify = PQnotifies(g_pg_notify_conn);
|
||||
if (notify) {
|
||||
strncpy(event_id_out, notify->extra, max_len);
|
||||
PQfreemem(notify);
|
||||
return 1; // Got a notification
|
||||
}
|
||||
return 0; // No notifications
|
||||
}
|
||||
```
|
||||
|
||||
#### Connection Management
|
||||
|
||||
```c
|
||||
// db_ops_postgres.c connection pool (simple version)
|
||||
#define DB_POOL_SIZE 4
|
||||
|
||||
static PGconn* g_pg_read_pool[DB_POOL_SIZE]; // For REQ queries
|
||||
static PGconn* g_pg_write_conn; // For EVENT inserts
|
||||
static PGconn* g_pg_notify_conn; // For LISTEN/NOTIFY
|
||||
static pthread_mutex_t g_pool_lock;
|
||||
|
||||
PGconn* db_get_read_connection(void) {
|
||||
pthread_mutex_lock(&g_pool_lock);
|
||||
// Round-robin or find idle connection
|
||||
// ...
|
||||
pthread_mutex_unlock(&g_pool_lock);
|
||||
}
|
||||
```
|
||||
|
||||
### Build System Changes
|
||||
|
||||
```makefile
|
||||
# Makefile additions
|
||||
DB_BACKEND ?= sqlite # Default to sqlite, override with: make DB_BACKEND=postgres
|
||||
|
||||
ifeq ($(DB_BACKEND),postgres)
|
||||
MAIN_SRC += src/db_ops.c src/db_ops_postgres.c
|
||||
LIBS += -lpq
|
||||
CFLAGS += -DDB_BACKEND_POSTGRES
|
||||
else
|
||||
MAIN_SRC += src/db_ops.c src/db_ops_sqlite.c
|
||||
CFLAGS += -DDB_BACKEND_SQLITE
|
||||
endif
|
||||
```
|
||||
|
||||
### Data Migration Tool
|
||||
|
||||
A standalone tool to migrate existing SQLite data to PostgreSQL:
|
||||
|
||||
```bash
|
||||
# migrate_to_postgres.sh
|
||||
# 1. Export events from SQLite
|
||||
sqlite3 old_relay.db ".mode csv" "SELECT id,pubkey,created_at,kind,event_type,content,sig,tags,event_json,first_seen FROM events" > events.csv
|
||||
|
||||
# 2. Import into PostgreSQL
|
||||
psql -d crelay -c "\COPY events FROM 'events.csv' WITH CSV"
|
||||
|
||||
# 3. Migrate config
|
||||
sqlite3 old_relay.db ".mode csv" "SELECT key,value,data_type,description,category,requires_restart FROM config" > config.csv
|
||||
psql -d crelay -c "\COPY config(key,value,data_type,description,category,requires_restart) FROM 'config.csv' WITH CSV"
|
||||
|
||||
# 4. Migrate auth rules
|
||||
sqlite3 old_relay.db ".mode csv" "SELECT rule_type,pattern_type,pattern_value,active FROM auth_rules" > auth_rules.csv
|
||||
psql -d crelay -c "\COPY auth_rules(rule_type,pattern_type,pattern_value,active) FROM 'auth_rules.csv' WITH CSV"
|
||||
|
||||
# 5. Refresh materialized views
|
||||
psql -d crelay -c "REFRESH MATERIALIZED VIEW event_kinds_mv; REFRESH MATERIALIZED VIEW time_stats_mv; REFRESH MATERIALIZED VIEW top_pubkeys_mv;"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Multi-Instance + Dashboard
|
||||
|
||||
### Goal
|
||||
|
||||
Run multiple c-relay-pg instances behind a load balancer with a separate dashboard web server, all sharing the same PostgreSQL database.
|
||||
|
||||
### Components
|
||||
|
||||
1. **nginx config** — WebSocket load balancing with `ip_hash` stickiness
|
||||
2. **systemd template** — `c-relay-pg@.service` for multiple instances
|
||||
3. **LISTEN/NOTIFY integration** — Cross-instance event broadcasting in the `lws_service()` loop
|
||||
4. **PgBouncer** — Connection pooling between c-relay-pg instances and PostgreSQL
|
||||
5. **Dashboard** — Separate web server querying PostgreSQL directly
|
||||
6. **Materialized view refresh** — Background job to refresh analytics views
|
||||
|
||||
### Multi-Instance Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
INTERNET[Internet - Nostr Clients] -->|wss://relay.example.com| NGINX[nginx reverse proxy - TLS termination + load balancing]
|
||||
|
||||
NGINX -->|ws://localhost:8888| R1[c-relay-pg Instance 1 - port 8888]
|
||||
NGINX -->|ws://localhost:8889| R2[c-relay-pg Instance 2 - port 8889]
|
||||
NGINX -->|ws://localhost:8890| R3[c-relay-pg Instance 3 - port 8890]
|
||||
NGINX -->|http://localhost:3000| DASHWEB[Dashboard Web Server]
|
||||
|
||||
R1 -->|libpq connection pool| PGPOOL[PgBouncer - connection pooler]
|
||||
R2 -->|libpq connection pool| PGPOOL
|
||||
R3 -->|libpq connection pool| PGPOOL
|
||||
|
||||
PGPOOL -->|pooled connections| PG[PostgreSQL Primary]
|
||||
DASHWEB -->|read queries| PG
|
||||
|
||||
PG -->|streaming replication| REPLICA[Read Replica - optional]
|
||||
DASHWEB -.->|heavy analytics| REPLICA
|
||||
```
|
||||
|
||||
### nginx Load Balancing Config
|
||||
|
||||
```nginx
|
||||
# nginx.conf - WebSocket load balancing for c-relay-pg
|
||||
upstream relay_backends {
|
||||
# ip_hash ensures a client always hits the same instance
|
||||
# (important for WebSocket session stickiness)
|
||||
ip_hash;
|
||||
|
||||
server 127.0.0.1:8888;
|
||||
server 127.0.0.1:8889;
|
||||
server 127.0.0.1:8890;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name relay.example.com;
|
||||
|
||||
# TLS config...
|
||||
|
||||
location / {
|
||||
proxy_pass http://relay_backends;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 86400; # Keep WebSocket alive for 24h
|
||||
}
|
||||
|
||||
# Dashboard on separate path
|
||||
location /dashboard {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Session stickiness** (`ip_hash`) ensures that once a client connects to Instance 2, all their subsequent WebSocket frames go to Instance 2. This is important because subscriptions are held in-memory per instance.
|
||||
|
||||
### Starting Multiple Instances
|
||||
|
||||
Each instance is the same binary, just on a different port, all pointing to the same PostgreSQL:
|
||||
|
||||
```bash
|
||||
# Instance 1
|
||||
./build/c_relay_x86 --port 8888 --db-host localhost --db-name crelay &
|
||||
|
||||
# Instance 2
|
||||
./build/c_relay_x86 --port 8889 --db-host localhost --db-name crelay &
|
||||
|
||||
# Instance 3
|
||||
./build/c_relay_x86 --port 8890 --db-host localhost --db-name crelay &
|
||||
```
|
||||
|
||||
Or with systemd template units:
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/c-relay-pg@.service
|
||||
[Unit]
|
||||
Description=C-Relay-PG Nostr Instance %i
|
||||
After=postgresql.service
|
||||
|
||||
[Service]
|
||||
ExecStart=/opt/c-relay-pg/c_relay_x86 --port %i --db-host localhost --db-name crelay
|
||||
Restart=always
|
||||
User=c-relay
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
systemctl enable c-relay-pg@8888 c-relay-pg@8889 c-relay-pg@8890
|
||||
systemctl start c-relay-pg@8888 c-relay-pg@8889 c-relay-pg@8890
|
||||
```
|
||||
|
||||
### Cross-Instance Event Broadcasting
|
||||
|
||||
When Instance 1 receives a new EVENT and stores it in PostgreSQL, Instance 2 and Instance 3 need to know about it so they can broadcast to their connected subscribers.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client_A as Client A - connected to Instance 1
|
||||
participant I1 as Instance 1
|
||||
participant PG as PostgreSQL
|
||||
participant I2 as Instance 2
|
||||
participant I3 as Instance 3
|
||||
participant Client_B as Client B - connected to Instance 2
|
||||
participant Client_C as Client C - connected to Instance 3
|
||||
|
||||
Client_A->>I1: EVENT - new kind:1 note
|
||||
I1->>PG: INSERT INTO events...
|
||||
PG-->>I1: OK
|
||||
I1->>I1: broadcast to local subscribers
|
||||
I1->>PG: NOTIFY new_event with event_id
|
||||
|
||||
Note over PG: PostgreSQL delivers notification to all listeners
|
||||
|
||||
PG-->>I2: NOTIFY: new_event event_id
|
||||
PG-->>I3: NOTIFY: new_event event_id
|
||||
|
||||
I2->>PG: SELECT event_json FROM events WHERE id = event_id
|
||||
PG-->>I2: event JSON
|
||||
I2->>I2: match against local subscriptions
|
||||
I2->>Client_B: EVENT message - if subscription matches
|
||||
|
||||
I3->>PG: SELECT event_json FROM events WHERE id = event_id
|
||||
PG-->>I3: event JSON
|
||||
I3->>I3: match against local subscriptions
|
||||
I3->>Client_C: EVENT message - if subscription matches
|
||||
```
|
||||
|
||||
#### PostgreSQL LISTEN/NOTIFY Implementation
|
||||
|
||||
Built into PostgreSQL — no additional infrastructure needed:
|
||||
|
||||
```c
|
||||
// === In the relay's event loop (modified lws_service loop) ===
|
||||
|
||||
// Setup: create a dedicated connection for LISTEN
|
||||
PGconn* notify_conn = PQconnectdb("host=localhost dbname=crelay");
|
||||
PQexec(notify_conn, "LISTEN new_event");
|
||||
int notify_fd = PQsocket(notify_conn); // Get the socket fd for poll()
|
||||
|
||||
// After storing an event:
|
||||
void on_event_stored(PGconn* write_conn, const char* event_id) {
|
||||
char notify_cmd[128];
|
||||
snprintf(notify_cmd, sizeof(notify_cmd),
|
||||
"NOTIFY new_event, '%s'", event_id);
|
||||
PQexec(write_conn, notify_cmd);
|
||||
}
|
||||
|
||||
// In the main event loop (runs every lws_service iteration):
|
||||
void check_cross_instance_events(PGconn* notify_conn) {
|
||||
// Non-blocking check for notifications
|
||||
PQconsumeInput(notify_conn);
|
||||
|
||||
PGnotify* notify;
|
||||
while ((notify = PQnotifies(notify_conn)) != NULL) {
|
||||
// Another instance stored a new event
|
||||
const char* event_id = notify->extra;
|
||||
|
||||
// Fetch the event and check against local subscriptions
|
||||
cJSON* event = db_get_event_by_id(event_id);
|
||||
if (event) {
|
||||
broadcast_event_to_subscriptions(event);
|
||||
cJSON_Delete(event);
|
||||
}
|
||||
|
||||
PQfreemem(notify);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Performance**: LISTEN/NOTIFY adds ~1–5ms latency for cross-instance delivery. For a Nostr relay, this is imperceptible — clients already expect network latency.
|
||||
|
||||
**Payload limit**: NOTIFY payloads are limited to 8000 bytes. For event IDs (64 hex chars), this is fine.
|
||||
|
||||
#### Alternative: Redis Pub/Sub
|
||||
|
||||
If you later need even lower latency or more sophisticated routing:
|
||||
|
||||
```c
|
||||
// Using hiredis (Redis C client)
|
||||
redisContext* redis = redisConnect("127.0.0.1", 6379);
|
||||
|
||||
// After storing event:
|
||||
redisCommand(redis, "PUBLISH new_event %s", event_json);
|
||||
|
||||
// Subscriber (in each instance):
|
||||
redisCommand(redis, "SUBSCRIBE new_event");
|
||||
// Then poll for messages in the event loop
|
||||
```
|
||||
|
||||
Redis adds sub-millisecond pub/sub but requires running a Redis server. For most relays, PostgreSQL LISTEN/NOTIFY is sufficient.
|
||||
|
||||
### Connection Pooling with PgBouncer
|
||||
|
||||
Each relay instance needs multiple database connections. Without pooling, 3 instances × 10 connections = 30 PostgreSQL backend processes. With PgBouncer:
|
||||
|
||||
```ini
|
||||
# /etc/pgbouncer/pgbouncer.ini
|
||||
[databases]
|
||||
crelay = host=127.0.0.1 port=5432 dbname=crelay
|
||||
|
||||
[pgbouncer]
|
||||
listen_port = 6432
|
||||
listen_addr = 127.0.0.1
|
||||
auth_type = md5
|
||||
pool_mode = transaction # Return connection to pool after each transaction
|
||||
max_client_conn = 200 # Total connections from all relay instances
|
||||
default_pool_size = 20 # Actual PostgreSQL connections
|
||||
```
|
||||
|
||||
Relay instances connect to PgBouncer (port 6432) instead of PostgreSQL directly (port 5432). PgBouncer multiplexes 200 client connections onto 20 actual PostgreSQL connections.
|
||||
|
||||
### Zero-Downtime Deployment
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant LB as nginx
|
||||
participant I1 as Instance 1 - v1.0
|
||||
participant I2 as Instance 2 - v1.0
|
||||
participant I3 as Instance 3 - v1.0
|
||||
participant I1_NEW as Instance 1 - v1.1
|
||||
|
||||
Note over LB,I3: Normal operation: 3 instances serving traffic
|
||||
|
||||
LB->>I1: Mark upstream as down
|
||||
Note over I1: Drain: wait for existing connections to close or timeout
|
||||
I1->>I1: Graceful shutdown
|
||||
|
||||
Note over LB: Traffic now goes to I2 and I3 only
|
||||
|
||||
I1_NEW->>I1_NEW: Start with new binary
|
||||
I1_NEW->>LB: Health check passes
|
||||
LB->>I1_NEW: Mark upstream as up
|
||||
|
||||
Note over LB,I1_NEW: Instance 1 now running v1.1, repeat for I2 and I3
|
||||
```
|
||||
|
||||
Rolling deploy script:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# rolling_deploy.sh - Zero-downtime deployment
|
||||
|
||||
for port in 8888 8889 8890; do
|
||||
echo "Deploying instance on port $port..."
|
||||
|
||||
# 1. Tell nginx to stop sending new connections
|
||||
sed -i "s/server 127.0.0.1:$port;/server 127.0.0.1:$port down;/" /etc/nginx/nginx.conf
|
||||
nginx -s reload
|
||||
|
||||
# 2. Wait for existing connections to drain (30 seconds)
|
||||
sleep 30
|
||||
|
||||
# 3. Stop old instance
|
||||
systemctl stop c-relay-pg@$port
|
||||
|
||||
# 4. Deploy new binary
|
||||
cp ./build/c_relay_x86 /opt/c-relay-pg/c_relay_x86
|
||||
|
||||
# 5. Start new instance
|
||||
systemctl start c-relay-pg@$port
|
||||
|
||||
# 6. Wait for health check
|
||||
sleep 5
|
||||
|
||||
# 7. Re-enable in nginx
|
||||
sed -i "s/server 127.0.0.1:$port down;/server 127.0.0.1:$port;/" /etc/nginx/nginx.conf
|
||||
nginx -s reload
|
||||
|
||||
echo "Instance on port $port deployed successfully"
|
||||
done
|
||||
```
|
||||
|
||||
### Dashboard Options
|
||||
|
||||
With PostgreSQL, the dashboard can be built with any technology:
|
||||
|
||||
- **Grafana** — Point directly at PostgreSQL, zero custom code
|
||||
- **Custom web app** — React/Vue frontend + any backend (Node, Python, Go) querying PostgreSQL
|
||||
- **Embedded in c-relay-pg** — Keep current approach but queries go through `db_ops` to PostgreSQL (no longer blocks event loop since PostgreSQL handles concurrency)
|
||||
|
||||
### Scaling Scenarios
|
||||
|
||||
| Scenario | Instances | Connections | Events/hour | Setup |
|
||||
|----------|-----------|-------------|-------------|-------|
|
||||
| **Current** | 1 | ~1,200 | 56 | Single process + SQLite |
|
||||
| **Small upgrade** | 2 | ~2,500 | 500 | 2 instances + PostgreSQL |
|
||||
| **Medium relay** | 4 | ~5,000 | 5,000 | 4 instances + PostgreSQL + PgBouncer |
|
||||
| **Large relay** | 8 | ~10,000 | 50,000 | 8 instances + PostgreSQL + read replica |
|
||||
| **Multi-region** | 2–4 per region | ~50,000 | 500,000 | Multiple servers + PostgreSQL replication |
|
||||
|
||||
### Cost Perspective
|
||||
|
||||
Running multiple relay instances with PostgreSQL on a single VPS:
|
||||
- **PostgreSQL**: ~200–500 MB RAM baseline
|
||||
- **PgBouncer**: ~10 MB RAM
|
||||
- **Each relay instance**: ~50–100 MB RAM
|
||||
- **Dashboard web server**: ~50–100 MB RAM
|
||||
- **4 instances + PostgreSQL + PgBouncer + dashboard**: ~1–2 GB total RAM
|
||||
- A **$20/month VPS** with 4 cores and 4 GB RAM handles this easily
|
||||
- A **$40/month VPS** with 8 cores and 8 GB RAM handles 8 instances comfortably
|
||||
|
||||
---
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|-----------|
|
||||
| PostgreSQL connection failures | `db_ops` returns error codes. Relay logs errors but doesn't crash. Reconnection logic with exponential backoff. |
|
||||
| Performance regression | Benchmark before/after. PostgreSQL should be faster for complex queries, similar for simple ones. |
|
||||
| Data loss during migration | Migration tool is read-only on SQLite. PostgreSQL import is idempotent (INSERT ON CONFLICT). |
|
||||
| SQLite fallback needed | Keep `db_ops_sqlite.c` working. Compile-time flag switches backends. Can always go back. |
|
||||
| LISTEN/NOTIFY message loss | NOTIFY is transactional — if the INSERT commits, the NOTIFY is guaranteed. Missed notifications during reconnect handled by periodic full-sync. |
|
||||
| PgBouncer adds latency | Transaction pooling mode adds <0.1ms. Negligible compared to query time. |
|
||||
|
||||
---
|
||||
|
||||
## Relationship to c-relay
|
||||
|
||||
This project depends on the `db_ops.h` abstraction layer built in [c-relay thread pool plan](c_relay_thread_pool_plan.md) Phase 1. The interface is identical — only the backend implementation changes:
|
||||
|
||||
- **c-relay**: `db_ops.c` → SQLite calls via `sqlite3_*`
|
||||
- **c-relay-pg**: `db_ops_postgres.c` → PostgreSQL calls via `libpq` (`PQexec`, `PQgetvalue`, etc.)
|
||||
|
||||
The `db_ops.h` header file is shared between both projects. Changes to the interface should be coordinated.
|
||||
@@ -0,0 +1,332 @@
|
||||
# c-relay Thread Pool Plan — db_ops Abstraction + SQLite Thread Pool
|
||||
|
||||
## Overview
|
||||
|
||||
This plan covers improvements to **c-relay** (this repo) — the lean, single-binary, embedded-SQLite Nostr relay. Two phases:
|
||||
|
||||
1. **Phase 1: Database Abstraction Layer** — Consolidate all scattered `sqlite3_*` calls into a single `db_ops.h` / `db_ops.c` module. Pure refactor, no behavior change.
|
||||
2. **Phase 2: SQLite Thread Pool** — Add worker threads for concurrent reads, unblocking the `lws_service()` event loop.
|
||||
|
||||
This is the **final form of c-relay**: an embedded-SQLite relay with clean architecture and non-blocking database access. The abstraction layer also enables a future fork to PostgreSQL — see [c-relay-pg plan](c_relay_pg_plan.md).
|
||||
|
||||
### Why This First?
|
||||
|
||||
The current architecture has a fundamental bottleneck documented in the [database architecture analysis](database_architecture_analysis.md): the single-threaded event loop blocks on every database call. A 672ms REQ query freezes every connected client. The thread pool fixes this without changing the database engine or deployment model.
|
||||
|
||||
| Metric | Current | After Thread Pool |
|
||||
|--------|---------|-------------------|
|
||||
| **Event loop blocking** | 0.1–672ms per query | Near-zero |
|
||||
| **Concurrent reads** | 1 | 4–8 |
|
||||
| **Deployment** | Single binary + DB file | Same |
|
||||
| **Database** | SQLite | Same |
|
||||
| **Code risk** | N/A | Low — additive change |
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph c-relay - Final Form
|
||||
RELAY[c-relay binary] -->|db_ops API| DBOPS[db_ops.c - abstraction layer]
|
||||
DBOPS -->|sqlite3 calls| SQLITE[SQLite WAL database]
|
||||
end
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph c-relay with Thread Pool
|
||||
LWS[lws_service event loop] -->|REQ arrives| Q[Thread-safe job queue]
|
||||
LWS -->|EVENT arrives| WQ[Write queue - single writer]
|
||||
Q --> T1[Reader Thread 1 - own sqlite3*]
|
||||
Q --> T2[Reader Thread 2 - own sqlite3*]
|
||||
Q --> T3[Reader Thread 3 - own sqlite3*]
|
||||
Q --> T4[Reader Thread 4 - own sqlite3*]
|
||||
WQ --> TW[Writer Thread - own sqlite3*]
|
||||
T1 -->|results| CB[Callback to lws event loop]
|
||||
T2 -->|results| CB
|
||||
T3 -->|results| CB
|
||||
T4 -->|results| CB
|
||||
TW -->|OK/error| CB
|
||||
CB -->|queue_message| LWS
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Database Abstraction Layer
|
||||
|
||||
### Goal
|
||||
|
||||
Consolidate all 258 scattered `sqlite3_*` calls across 8 files into a single `db_ops.h` / `db_ops.c` module with a backend-agnostic interface. SQLite continues to work — this is a pure refactor.
|
||||
|
||||
### Files That Currently Touch SQLite Directly
|
||||
|
||||
| File | `sqlite3_*` calls | Operations |
|
||||
|------|-------------------|------------|
|
||||
| [`src/main.c`](../src/main.c) | ~80 | Event store/retrieve, tag storage, REQ queries, COUNT queries, DB init, schema migration |
|
||||
| [`src/config.c`](../src/config.c) | ~60 | Config CRUD, auth rules CRUD, WoT sync, relay key storage, startup sequence |
|
||||
| [`src/api.c`](../src/api.c) | ~40 | Stats queries, monitoring views, admin SQL execution, config management |
|
||||
| [`src/dm_admin.c`](../src/dm_admin.c) | ~20 | Auth rule management, WoT whitelist operations |
|
||||
| [`src/websockets.c`](../src/websockets.c) | ~15 | COUNT queries, IP ban stats, connection tracking |
|
||||
| [`src/subscriptions.c`](../src/subscriptions.c) | ~15 | Subscription logging — create/close/disconnect |
|
||||
| [`src/nip009.c`](../src/nip009.c) | ~10 | Event deletion — by ID and by address |
|
||||
| [`src/request_validator.c`](../src/request_validator.c) | ~8 | Blacklist/whitelist checks |
|
||||
| [`src/ip_ban.c`](../src/ip_ban.c) | ~15 | IP ban table CRUD, persistence |
|
||||
|
||||
### Abstraction Layer Design
|
||||
|
||||
New files: `src/db_ops.h` and `src/db_ops.c`
|
||||
|
||||
```c
|
||||
// src/db_ops.h — Database operations abstraction layer
|
||||
#ifndef DB_OPS_H
|
||||
#define DB_OPS_H
|
||||
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
// ============================================================
|
||||
// Lifecycle
|
||||
// ============================================================
|
||||
int db_init(const char* connection_string); // SQLite: file path, PG: connection string
|
||||
void db_close(void);
|
||||
int db_is_available(void);
|
||||
|
||||
// ============================================================
|
||||
// Schema / Migration
|
||||
// ============================================================
|
||||
int db_ensure_schema(void);
|
||||
int db_get_schema_version(void);
|
||||
int db_execute_raw(const char* sql); // For schema DDL only
|
||||
|
||||
// ============================================================
|
||||
// Event Operations
|
||||
// ============================================================
|
||||
int db_store_event(cJSON* event);
|
||||
int db_event_exists(const char* event_id);
|
||||
char* db_get_event_json(const char* event_id); // Caller must free
|
||||
int db_delete_event_by_id(const char* event_id, const char* requester_pubkey);
|
||||
int db_delete_events_by_address(const char* pubkey, int kind,
|
||||
const char* d_tag, long before_timestamp);
|
||||
|
||||
// ============================================================
|
||||
// Event Queries - REQ handling
|
||||
// ============================================================
|
||||
typedef struct {
|
||||
int* kinds; int kind_count;
|
||||
char** authors; int author_count;
|
||||
char** ids; int id_count;
|
||||
char** tag_names; // Parallel arrays: tag_names[i] has tag_values[i][]
|
||||
char*** tag_values;
|
||||
int* tag_value_counts;
|
||||
int tag_filter_count;
|
||||
long since; // 0 = not set
|
||||
long until; // 0 = not set
|
||||
int limit; // 0 = default 500
|
||||
char* search; // NIP-50 search term, NULL = not set
|
||||
} db_event_filter_t;
|
||||
|
||||
typedef struct db_result db_result_t;
|
||||
|
||||
db_result_t* db_query_events(const db_event_filter_t* filter);
|
||||
const char* db_result_next_json(db_result_t* result); // Returns event_json, NULL when done
|
||||
int db_result_row_count(db_result_t* result);
|
||||
void db_result_free(db_result_t* result);
|
||||
|
||||
int db_count_events(const db_event_filter_t* filter);
|
||||
|
||||
// ============================================================
|
||||
// Config Operations
|
||||
// ============================================================
|
||||
char* db_get_config(const char* key); // Caller must free, NULL if not found
|
||||
int db_set_config(const char* key, const char* value, const char* data_type,
|
||||
const char* description, const char* category, int requires_restart);
|
||||
int db_update_config(const char* key, const char* value);
|
||||
int db_config_exists(const char* key);
|
||||
int db_get_config_count(void);
|
||||
|
||||
// ============================================================
|
||||
// Auth Rules Operations
|
||||
// ============================================================
|
||||
int db_add_auth_rule(const char* rule_type, const char* pattern_type, const char* pattern_value);
|
||||
int db_remove_auth_rule(const char* rule_type, const char* pattern_type, const char* pattern_value);
|
||||
int db_auth_rule_exists(const char* rule_type, const char* pattern_type, const char* pattern_value);
|
||||
int db_clear_auth_rules(const char* rule_type); // NULL = clear all
|
||||
int db_is_blacklisted(const char* pubkey);
|
||||
int db_is_whitelisted(const char* pubkey);
|
||||
int db_has_any_whitelist(void);
|
||||
|
||||
// ============================================================
|
||||
// Relay Key Storage
|
||||
// ============================================================
|
||||
int db_store_relay_private_key(const char* privkey_hex);
|
||||
char* db_get_relay_private_key(void); // Caller must free
|
||||
|
||||
// ============================================================
|
||||
// Subscription Logging
|
||||
// ============================================================
|
||||
int db_log_subscription_created(const char* sub_id, const char* wsi_ptr,
|
||||
const char* client_ip, const char* filter_json);
|
||||
int db_log_subscription_closed(const char* sub_id, const char* client_ip);
|
||||
int db_log_subscription_disconnected(const char* client_ip);
|
||||
int db_update_subscription_events_sent(const char* sub_id, int events_sent);
|
||||
int db_cleanup_orphaned_subscriptions(void);
|
||||
|
||||
// ============================================================
|
||||
// IP Ban Persistence
|
||||
// ============================================================
|
||||
int db_ensure_ip_ban_table(void);
|
||||
int db_load_ip_bans(void* ban_table, int table_size); // Populates in-memory table
|
||||
int db_save_ip_bans(const void* ban_table, int table_size);
|
||||
|
||||
// ============================================================
|
||||
// Analytics / Stats - for dashboard
|
||||
// ============================================================
|
||||
cJSON* db_get_event_kind_distribution(void);
|
||||
cJSON* db_get_time_based_stats(void);
|
||||
cJSON* db_get_top_pubkeys(int limit);
|
||||
cJSON* db_get_subscription_details(void);
|
||||
int db_get_total_event_count(void);
|
||||
|
||||
// ============================================================
|
||||
// Admin SQL Query
|
||||
// ============================================================
|
||||
cJSON* db_execute_admin_query(const char* sql, char* error_msg, size_t error_size);
|
||||
|
||||
#endif // DB_OPS_H
|
||||
```
|
||||
|
||||
### Migration Strategy for Phase 1
|
||||
|
||||
The key principle: **change the interface, not the behavior**. Each function in `db_ops.c` initially just wraps the existing SQLite calls.
|
||||
|
||||
**Step-by-step for each file:**
|
||||
|
||||
1. **Create `db_ops.h` and `db_ops.c`** with the interface above
|
||||
2. **Implement each `db_ops` function** by moving the existing SQLite code from the source files into `db_ops.c`
|
||||
3. **Replace direct `sqlite3_*` calls** in each source file with `db_ops_*` calls
|
||||
4. **Remove `extern sqlite3* g_db`** from each file — only `db_ops.c` knows about `g_db`
|
||||
5. **Remove `#include <sqlite3.h>`** from each file — only `db_ops.c` includes it
|
||||
6. **Update Makefile** to compile `db_ops.c`
|
||||
|
||||
### Order of Migration (by risk, lowest first)
|
||||
|
||||
1. **`src/ip_ban.c`** — Self-contained, simple CRUD. Good warmup.
|
||||
2. **`src/subscriptions.c`** — Logging only, no critical path.
|
||||
3. **`src/nip009.c`** — Event deletion, small file.
|
||||
4. **`src/request_validator.c`** — Auth checks, small file.
|
||||
5. **`src/api.c`** — Stats/monitoring queries. Larger but read-only.
|
||||
6. **`src/dm_admin.c`** — Auth rules + WoT. Medium complexity.
|
||||
7. **`src/config.c`** — Config CRUD. Large but well-structured.
|
||||
8. **`src/websockets.c`** — COUNT queries, minimal DB usage.
|
||||
9. **`src/main.c`** — Event store/retrieve, REQ queries. The big one — do last.
|
||||
|
||||
### Testing Phase 1
|
||||
|
||||
After Phase 1, the relay should behave **identically** to before. Run:
|
||||
- `tests/run_all_tests.sh` — Full test suite
|
||||
- `tests/performance_benchmarks.sh` — Verify no performance regression
|
||||
- Manual testing with production-like traffic
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: SQLite Thread Pool
|
||||
|
||||
### Goal
|
||||
|
||||
Add a pool of N worker threads, each with its own `sqlite3*` connection to the same database file. SQLite WAL mode (already enabled) supports **concurrent readers**. The event loop dispatches database work to the pool and remains responsive.
|
||||
|
||||
### Key Design Points
|
||||
|
||||
1. **Read path**: REQ queries dispatched to thread pool. Each worker opens its own `sqlite3*` connection. SQLite WAL allows unlimited concurrent readers.
|
||||
|
||||
2. **Write path**: EVENT inserts go through a single dedicated writer thread. SQLite only allows one writer at a time anyway — this serializes writes cleanly.
|
||||
|
||||
3. **Result delivery**: Worker threads cannot call `lws_write()` directly (libwebsockets is not thread-safe). Instead, they push results into a per-session message queue and call `lws_cancel_service()` to wake the event loop, which then drains the queue.
|
||||
|
||||
4. **Connection lifecycle**: Each thread opens its own connection with `PRAGMA journal_mode=WAL` and `PRAGMA busy_timeout=5000`.
|
||||
|
||||
### What Changes in the Codebase
|
||||
|
||||
| Component | Current | Thread Pool |
|
||||
|-----------|---------|-------------|
|
||||
| [`g_db`](../src/main.c:49) | Single global connection | One per thread + writer connection |
|
||||
| [`handle_req_message()`](../src/main.c:1101) | Synchronous SQL in callback | Package filter into job, dispatch to pool |
|
||||
| [`store_event()`](../src/main.c:787) | Synchronous INSERT in callback | Dispatch to writer thread |
|
||||
| [`handle_count_message()`](../src/websockets.c:2863) | Synchronous COUNT in callback | Dispatch to pool |
|
||||
| Result delivery | Direct `queue_message()` | Worker pushes to queue + `lws_cancel_service()` |
|
||||
| Config reads | Direct `sqlite3_prepare` on `g_db` | Can stay synchronous with own connection or cache |
|
||||
|
||||
### New Files
|
||||
|
||||
- `src/thread_pool.h` — Thread pool interface
|
||||
- `src/thread_pool.c` — Thread pool implementation (job queue, worker lifecycle, result delivery)
|
||||
|
||||
### Thread Pool Interface (sketch)
|
||||
|
||||
```c
|
||||
// src/thread_pool.h
|
||||
#ifndef THREAD_POOL_H
|
||||
#define THREAD_POOL_H
|
||||
|
||||
typedef enum {
|
||||
JOB_TYPE_REQ_QUERY,
|
||||
JOB_TYPE_COUNT_QUERY,
|
||||
JOB_TYPE_STORE_EVENT,
|
||||
JOB_TYPE_DELETE_EVENT,
|
||||
} job_type_t;
|
||||
|
||||
typedef struct {
|
||||
job_type_t type;
|
||||
void* session; // lws per-session data pointer
|
||||
db_event_filter_t filter; // For REQ/COUNT jobs
|
||||
cJSON* event; // For STORE jobs
|
||||
char event_id[65]; // For DELETE jobs
|
||||
} db_job_t;
|
||||
|
||||
int thread_pool_init(int num_readers, const char* db_path);
|
||||
void thread_pool_shutdown(void);
|
||||
int thread_pool_submit_read(db_job_t* job);
|
||||
int thread_pool_submit_write(db_job_t* job);
|
||||
|
||||
#endif // THREAD_POOL_H
|
||||
```
|
||||
|
||||
### Performance Characteristics
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Concurrent reads** | N readers in parallel (N = thread count, typically 4–8) |
|
||||
| **Write throughput** | Same as current — SQLite serializes writes regardless |
|
||||
| **Event loop latency** | Near-zero — REQ no longer blocks the loop |
|
||||
| **Max theoretical read throughput** | ~4–8x current (limited by disk I/O, not CPU) |
|
||||
| **Memory overhead** | ~50–100 MB per connection (page cache) |
|
||||
| **Latency per query** | Same as current per-query, but no head-of-line blocking |
|
||||
|
||||
### Limitations
|
||||
|
||||
- **Write contention**: SQLite still allows only ONE writer at a time. With WAL, readers don't block writers and writers don't block readers, but two simultaneous writes will serialize. At the current write rate (~56 events/hour), this is a non-issue.
|
||||
- **Database size**: The 2.7 GB index bloat problem remains. Thread pool doesn't fix the schema — it fixes the concurrency.
|
||||
- **Scaling ceiling**: Beyond ~8 reader threads, diminishing returns due to disk I/O contention on a single SQLite file.
|
||||
- **Complexity**: Need a proper job queue, thread lifecycle management, and careful handling of the lws ↔ worker thread boundary.
|
||||
|
||||
---
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|-----------|
|
||||
| Phase 1 introduces bugs | Each file migrated independently, tested after each. Full test suite runs after each file. |
|
||||
| Thread pool race conditions | Worker threads only touch their own `sqlite3*` connection. Result delivery uses a mutex-protected queue. `lws_cancel_service()` is documented as thread-safe. |
|
||||
| Performance regression from abstraction | Abstraction layer is thin wrappers — no extra allocations or copies. Benchmark before/after. |
|
||||
| lws thread safety issues | Workers never call `lws_write()` directly. They push to a queue and wake the event loop. This is the documented pattern for libwebsockets multi-threading. |
|
||||
| Rollback needed | Phase 1 is a pure refactor — easy to revert. Phase 2 thread pool is additive — can be disabled with a compile flag. |
|
||||
|
||||
---
|
||||
|
||||
## Relationship to c-relay-pg
|
||||
|
||||
After Phase 1 (abstraction layer) is complete, the codebase is ready to be forked into a separate **c-relay-pg** project that replaces the SQLite backend with PostgreSQL. See [c-relay-pg plan](c_relay_pg_plan.md) for details.
|
||||
|
||||
The `db_ops.h` interface is designed to work with any backend:
|
||||
- **SQLite** (this plan): `db_result_next_json()` copies from `sqlite3_column_text()`
|
||||
- **PostgreSQL** (c-relay-pg): `db_result_next_json()` returns from `PQgetvalue()`
|
||||
- **LMDB** (future possibility): `db_result_next_json()` returns a zero-copy pointer into mmap'd memory
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 |
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
#define _GNU_SOURCE
|
||||
|
||||
#include "db_ops.h"
|
||||
#include "debug.h"
|
||||
#include "config.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
// Database handle owned by main.c today.
|
||||
extern sqlite3* g_db;
|
||||
extern char g_database_path[512];
|
||||
|
||||
int db_is_available(void) {
|
||||
return g_db != NULL;
|
||||
}
|
||||
|
||||
sqlite3* db_get_handle(void) {
|
||||
return g_db;
|
||||
}
|
||||
|
||||
int db_log_subscription_created(const char* sub_id, const char* wsi_ptr,
|
||||
const char* client_ip, const char* filter_json) {
|
||||
if (!g_db || !sub_id || !wsi_ptr || !client_ip) return -1;
|
||||
|
||||
const char* sql =
|
||||
"INSERT OR REPLACE INTO subscriptions (subscription_id, wsi_pointer, client_ip, event_type, filter_json) "
|
||||
"VALUES (?, ?, ?, 'created', ?)";
|
||||
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL) != SQLITE_OK) return -1;
|
||||
|
||||
sqlite3_bind_text(stmt, 1, sub_id, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, wsi_ptr, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, client_ip, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 4, filter_json ? filter_json : "[]", -1, SQLITE_TRANSIENT);
|
||||
|
||||
int rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
return (rc == SQLITE_DONE) ? 0 : -1;
|
||||
}
|
||||
|
||||
int db_log_subscription_closed(const char* sub_id, const char* client_ip) {
|
||||
if (!g_db || !sub_id) return -1;
|
||||
|
||||
const char* insert_sql =
|
||||
"INSERT INTO subscriptions (subscription_id, wsi_pointer, client_ip, event_type) "
|
||||
"VALUES (?, '', ?, 'closed')";
|
||||
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
if (sqlite3_prepare_v2(g_db, insert_sql, -1, &stmt, NULL) == SQLITE_OK) {
|
||||
sqlite3_bind_text(stmt, 1, sub_id, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, client_ip ? client_ip : "unknown", -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
const char* update_sql =
|
||||
"UPDATE subscriptions "
|
||||
"SET ended_at = strftime('%s', 'now') "
|
||||
"WHERE subscription_id = ? AND event_type = 'created' AND ended_at IS NULL";
|
||||
|
||||
if (sqlite3_prepare_v2(g_db, update_sql, -1, &stmt, NULL) != SQLITE_OK) return -1;
|
||||
sqlite3_bind_text(stmt, 1, sub_id, -1, SQLITE_TRANSIENT);
|
||||
int rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
return (rc == SQLITE_DONE) ? 0 : -1;
|
||||
}
|
||||
|
||||
int db_log_subscription_disconnected(const char* client_ip) {
|
||||
if (!g_db || !client_ip) return -1;
|
||||
|
||||
const char* update_sql =
|
||||
"UPDATE subscriptions "
|
||||
"SET ended_at = strftime('%s', 'now') "
|
||||
"WHERE client_ip = ? AND event_type = 'created' AND ended_at IS NULL";
|
||||
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
if (sqlite3_prepare_v2(g_db, update_sql, -1, &stmt, NULL) != SQLITE_OK) return -1;
|
||||
|
||||
sqlite3_bind_text(stmt, 1, client_ip, -1, SQLITE_TRANSIENT);
|
||||
int rc = sqlite3_step(stmt);
|
||||
int changes = sqlite3_changes(g_db);
|
||||
sqlite3_finalize(stmt);
|
||||
if (rc != SQLITE_DONE) return -1;
|
||||
|
||||
if (changes > 0) {
|
||||
const char* insert_sql =
|
||||
"INSERT INTO subscriptions (subscription_id, wsi_pointer, client_ip, event_type) "
|
||||
"VALUES ('disconnect', '', ?, 'disconnected')";
|
||||
|
||||
if (sqlite3_prepare_v2(g_db, insert_sql, -1, &stmt, NULL) == SQLITE_OK) {
|
||||
sqlite3_bind_text(stmt, 1, client_ip, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
int db_update_subscription_events_sent(const char* sub_id, int events_sent) {
|
||||
if (!g_db || !sub_id) return -1;
|
||||
|
||||
const char* sql =
|
||||
"UPDATE subscriptions "
|
||||
"SET events_sent = ? "
|
||||
"WHERE subscription_id = ? AND event_type = 'created'";
|
||||
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL) != SQLITE_OK) return -1;
|
||||
|
||||
sqlite3_bind_int(stmt, 1, events_sent);
|
||||
sqlite3_bind_text(stmt, 2, sub_id, -1, SQLITE_TRANSIENT);
|
||||
|
||||
int rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
return (rc == SQLITE_DONE) ? 0 : -1;
|
||||
}
|
||||
|
||||
int db_cleanup_orphaned_subscriptions(void) {
|
||||
if (!g_db) return -1;
|
||||
|
||||
const char* sql =
|
||||
"UPDATE subscriptions "
|
||||
"SET ended_at = strftime('%s', 'now') "
|
||||
"WHERE event_type = 'created' AND ended_at IS NULL";
|
||||
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL) != SQLITE_OK) return -1;
|
||||
|
||||
int rc = sqlite3_step(stmt);
|
||||
int changes = sqlite3_changes(g_db);
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
return (rc == SQLITE_DONE) ? changes : -1;
|
||||
}
|
||||
|
||||
int db_get_event_pubkey(const char* event_id, char* pubkey_out, size_t pubkey_out_size) {
|
||||
if (!g_db || !event_id || !pubkey_out || pubkey_out_size == 0) return -1;
|
||||
|
||||
const char* sql = "SELECT pubkey FROM events WHERE id = ?";
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
|
||||
if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL) != SQLITE_OK) return -1;
|
||||
|
||||
sqlite3_bind_text(stmt, 1, event_id, -1, SQLITE_TRANSIENT);
|
||||
int rc = sqlite3_step(stmt);
|
||||
if (rc == SQLITE_ROW) {
|
||||
const char* pubkey = (const char*)sqlite3_column_text(stmt, 0);
|
||||
if (pubkey) {
|
||||
snprintf(pubkey_out, pubkey_out_size, "%s", pubkey);
|
||||
sqlite3_finalize(stmt);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
sqlite3_finalize(stmt);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int db_delete_event_by_id(const char* event_id, const char* requester_pubkey) {
|
||||
if (!g_db || !event_id || !requester_pubkey) return -1;
|
||||
|
||||
const char* sql = "DELETE FROM events WHERE id = ? AND pubkey = ?";
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
|
||||
if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL) != SQLITE_OK) return -1;
|
||||
|
||||
sqlite3_bind_text(stmt, 1, event_id, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, requester_pubkey, -1, SQLITE_TRANSIENT);
|
||||
|
||||
int rc = sqlite3_step(stmt);
|
||||
int changes = sqlite3_changes(g_db);
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (rc != SQLITE_DONE) return -1;
|
||||
return changes;
|
||||
}
|
||||
|
||||
int db_delete_events_by_address(const char* pubkey, int kind,
|
||||
const char* d_tag, long before_timestamp) {
|
||||
if (!g_db || !pubkey) return -1;
|
||||
|
||||
const char* sql_with_d =
|
||||
"DELETE FROM events WHERE kind = ? AND pubkey = ? AND created_at <= ? "
|
||||
"AND json_extract(tags, '$[*]') LIKE '%[\"d\",\"' || ? || '\"]%'";
|
||||
const char* sql_no_d =
|
||||
"DELETE FROM events WHERE kind = ? AND pubkey = ? AND created_at <= ?";
|
||||
|
||||
const int has_d = (d_tag && d_tag[0] != '\0');
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
|
||||
if (sqlite3_prepare_v2(g_db, has_d ? sql_with_d : sql_no_d, -1, &stmt, NULL) != SQLITE_OK) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
sqlite3_bind_int(stmt, 1, kind);
|
||||
sqlite3_bind_text(stmt, 2, pubkey, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int64(stmt, 3, before_timestamp);
|
||||
if (has_d) {
|
||||
sqlite3_bind_text(stmt, 4, d_tag, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
|
||||
int rc = sqlite3_step(stmt);
|
||||
int changes = sqlite3_changes(g_db);
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (rc != SQLITE_DONE) return -1;
|
||||
return changes;
|
||||
}
|
||||
|
||||
static int db_scalar_exists_open(sqlite3* db, const char* sql, const char* value) {
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) return 0;
|
||||
|
||||
if (value) sqlite3_bind_text(stmt, 1, value, -1, SQLITE_TRANSIENT);
|
||||
|
||||
int exists = (sqlite3_step(stmt) == SQLITE_ROW) ? 1 : 0;
|
||||
sqlite3_finalize(stmt);
|
||||
return exists;
|
||||
}
|
||||
|
||||
int db_is_pubkey_blacklisted(const char* pubkey) {
|
||||
if (!pubkey || g_database_path[0] == '\0') return 0;
|
||||
|
||||
sqlite3* db = NULL;
|
||||
if (sqlite3_open_v2(g_database_path, &db, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) return 0;
|
||||
|
||||
const char* sql =
|
||||
"SELECT 1 FROM auth_rules WHERE rule_type = 'blacklist' "
|
||||
"AND pattern_type = 'pubkey' AND pattern_value = ? AND active = 1 LIMIT 1";
|
||||
|
||||
int exists = db_scalar_exists_open(db, sql, pubkey);
|
||||
sqlite3_close(db);
|
||||
return exists;
|
||||
}
|
||||
|
||||
int db_is_hash_blacklisted(const char* resource_hash) {
|
||||
if (!resource_hash || g_database_path[0] == '\0') return 0;
|
||||
|
||||
sqlite3* db = NULL;
|
||||
if (sqlite3_open_v2(g_database_path, &db, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) return 0;
|
||||
|
||||
const char* sql =
|
||||
"SELECT 1 FROM auth_rules WHERE rule_type = 'blacklist' "
|
||||
"AND pattern_type = 'hash' AND pattern_value = ? AND active = 1 LIMIT 1";
|
||||
|
||||
int exists = db_scalar_exists_open(db, sql, resource_hash);
|
||||
sqlite3_close(db);
|
||||
return exists;
|
||||
}
|
||||
|
||||
int db_is_pubkey_whitelisted(const char* pubkey) {
|
||||
if (!pubkey || g_database_path[0] == '\0') return 0;
|
||||
|
||||
sqlite3* db = NULL;
|
||||
if (sqlite3_open_v2(g_database_path, &db, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) return 0;
|
||||
|
||||
const char* sql =
|
||||
"SELECT 1 FROM auth_rules WHERE rule_type IN ('whitelist', 'wot_whitelist') "
|
||||
"AND pattern_type = 'pubkey' AND pattern_value = ? AND active = 1 LIMIT 1";
|
||||
|
||||
int exists = db_scalar_exists_open(db, sql, pubkey);
|
||||
sqlite3_close(db);
|
||||
return exists;
|
||||
}
|
||||
|
||||
int db_count_active_whitelist_rules(void) {
|
||||
if (g_database_path[0] == '\0') return 0;
|
||||
|
||||
sqlite3* db = NULL;
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
int count = 0;
|
||||
|
||||
if (sqlite3_open_v2(g_database_path, &db, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) return 0;
|
||||
|
||||
const char* sql =
|
||||
"SELECT COUNT(*) FROM auth_rules WHERE rule_type IN ('whitelist', 'wot_whitelist') "
|
||||
"AND pattern_type = 'pubkey' AND active = 1 LIMIT 1";
|
||||
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) == SQLITE_OK) {
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
count = sqlite3_column_int(stmt, 0);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
sqlite3_close(db);
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#ifndef DB_OPS_H
|
||||
#define DB_OPS_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <sqlite3.h>
|
||||
|
||||
// Generic helpers
|
||||
int db_is_available(void);
|
||||
sqlite3* db_get_handle(void);
|
||||
|
||||
// Subscription logging
|
||||
int db_log_subscription_created(const char* sub_id, const char* wsi_ptr,
|
||||
const char* client_ip, const char* filter_json);
|
||||
int db_log_subscription_closed(const char* sub_id, const char* client_ip);
|
||||
int db_log_subscription_disconnected(const char* client_ip);
|
||||
int db_update_subscription_events_sent(const char* sub_id, int events_sent);
|
||||
int db_cleanup_orphaned_subscriptions(void);
|
||||
|
||||
// NIP-09 event deletion helpers
|
||||
int db_get_event_pubkey(const char* event_id, char* pubkey_out, size_t pubkey_out_size);
|
||||
int db_delete_event_by_id(const char* event_id, const char* requester_pubkey);
|
||||
int db_delete_events_by_address(const char* pubkey, int kind,
|
||||
const char* d_tag, long before_timestamp);
|
||||
|
||||
// Auth rule checks for request validator
|
||||
int db_is_pubkey_blacklisted(const char* pubkey);
|
||||
int db_is_hash_blacklisted(const char* resource_hash);
|
||||
int db_is_pubkey_whitelisted(const char* pubkey);
|
||||
int db_count_active_whitelist_rules(void);
|
||||
|
||||
#endif // DB_OPS_H
|
||||
@@ -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
+19
-9
@@ -2,6 +2,7 @@
|
||||
#include "ip_ban.h"
|
||||
#include "debug.h"
|
||||
#include "config.h"
|
||||
#include "db_ops.h"
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <pthread.h>
|
||||
@@ -100,7 +101,8 @@ void ip_ban_init(void) {
|
||||
DEBUG_LOG("IP ban table initialized (%d slots)", IP_BAN_TABLE_SIZE);
|
||||
}
|
||||
|
||||
void ip_ban_load_from_db(sqlite3* db) {
|
||||
void ip_ban_load_from_db(void) {
|
||||
sqlite3* db = db_get_handle();
|
||||
if (!db || !g_initialized) return;
|
||||
|
||||
// Create table if it doesn't exist (handles existing databases)
|
||||
@@ -193,7 +195,8 @@ void ip_ban_load_from_db(sqlite3* db) {
|
||||
DEBUG_WARN("IP ban table loaded: %d IPs restored (%d still banned)", loaded, still_banned);
|
||||
}
|
||||
|
||||
void ip_ban_save_to_db(sqlite3* db) {
|
||||
void ip_ban_save_to_db(void) {
|
||||
sqlite3* db = db_get_handle();
|
||||
if (!db || !g_initialized) return;
|
||||
|
||||
const char* upsert_sql =
|
||||
@@ -253,23 +256,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) {
|
||||
@@ -538,7 +550,7 @@ int ip_ban_get_tracked_count(void) {
|
||||
return count;
|
||||
}
|
||||
|
||||
void ip_ban_log_stats(sqlite3* db) {
|
||||
void ip_ban_log_stats(void) {
|
||||
if (!g_initialized) return;
|
||||
|
||||
static time_t last_log = 0;
|
||||
@@ -548,9 +560,7 @@ void ip_ban_log_stats(sqlite3* db) {
|
||||
last_log = now;
|
||||
|
||||
// Save to DB every 5 minutes
|
||||
if (db) {
|
||||
ip_ban_save_to_db(db);
|
||||
}
|
||||
ip_ban_save_to_db();
|
||||
|
||||
pthread_mutex_lock(&g_ban_mutex);
|
||||
int auth_banned_count = 0;
|
||||
|
||||
+3
-4
@@ -15,7 +15,6 @@
|
||||
// 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
|
||||
@@ -25,11 +24,11 @@ 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);
|
||||
void ip_ban_load_from_db(void);
|
||||
|
||||
// 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);
|
||||
void ip_ban_save_to_db(void);
|
||||
|
||||
// Check if an IP is currently banned.
|
||||
// Returns 1 if banned (connection should be rejected), 0 if allowed.
|
||||
@@ -57,7 +56,7 @@ 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);
|
||||
void ip_ban_log_stats(void);
|
||||
|
||||
// Get stats for logging/monitoring
|
||||
int ip_ban_get_banned_count(void);
|
||||
|
||||
+128
-4
@@ -26,6 +26,7 @@
|
||||
#include "websockets.h" // WebSocket protocol implementation
|
||||
#include "subscriptions.h" // Subscription management system
|
||||
#include "debug.h" // Debug system
|
||||
#include "thread_pool.h" // Thread pool scaffold
|
||||
|
||||
// Forward declarations for unified request validator
|
||||
int nostr_validate_unified_request(const char* json_string, size_t json_length);
|
||||
@@ -162,9 +163,12 @@ 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);
|
||||
|
||||
// Thread pool wake callback (called by worker threads)
|
||||
static void wake_event_loop_from_thread_pool(void* ctx);
|
||||
|
||||
// Forward declarations for IP ban system
|
||||
void ip_ban_init(void);
|
||||
void ip_ban_load_from_db(sqlite3* db);
|
||||
void ip_ban_load_from_db(void);
|
||||
|
||||
|
||||
// Forward declarations for NIP-13 PoW handling (now in nip013.c)
|
||||
@@ -186,6 +190,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);
|
||||
@@ -205,6 +256,13 @@ void signal_handler(int sig) {
|
||||
}
|
||||
}
|
||||
|
||||
static void wake_event_loop_from_thread_pool(void* ctx) {
|
||||
(void)ctx;
|
||||
if (ws_context) {
|
||||
lws_cancel_service(ws_context);
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
// NOTICE MESSAGE SUPPORT
|
||||
@@ -738,6 +796,10 @@ int store_event_tags(const char* event_id, cJSON* tags) {
|
||||
|
||||
// Store event in database
|
||||
int store_event(cJSON* event) {
|
||||
if (thread_pool_is_running()) {
|
||||
DEBUG_TRACE("Thread pool scaffold active: store_event() still using synchronous DB path");
|
||||
}
|
||||
|
||||
if (!g_db || !event) {
|
||||
return -1;
|
||||
}
|
||||
@@ -1052,6 +1114,10 @@ static int is_only_kind_99999_request(cJSON* filters) {
|
||||
}
|
||||
|
||||
int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, struct per_session_data *pss) {
|
||||
if (thread_pool_is_running()) {
|
||||
DEBUG_TRACE("Thread pool scaffold active: handle_req_message() still using synchronous DB path");
|
||||
}
|
||||
|
||||
if (!cJSON_IsArray(filters)) {
|
||||
DEBUG_ERROR("REQ filters is not an array");
|
||||
return 0;
|
||||
@@ -1260,6 +1326,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 +1723,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 +1735,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 +1742,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 +1763,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 +1776,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 +1785,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 +1998,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 +2152,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");
|
||||
@@ -2171,11 +2270,35 @@ int main(int argc, char* argv[]) {
|
||||
|
||||
// Initialize IP ban table and load persisted state from database
|
||||
ip_ban_init();
|
||||
ip_ban_load_from_db(g_db);
|
||||
ip_ban_load_from_db();
|
||||
|
||||
// Optional thread pool scaffold initialization (execution wiring is future work)
|
||||
int thread_pool_enabled = get_config_bool("thread_pool_enabled", 0);
|
||||
int thread_pool_initialized = 0;
|
||||
if (thread_pool_enabled) {
|
||||
thread_pool_config_t tp_cfg;
|
||||
memset(&tp_cfg, 0, sizeof(tp_cfg));
|
||||
tp_cfg.reader_threads = get_config_int("thread_pool_readers", 4);
|
||||
tp_cfg.max_queue_depth = get_config_int("thread_pool_max_queue_depth", 4096);
|
||||
tp_cfg.db_path = g_database_path;
|
||||
tp_cfg.wake_loop_cb = wake_event_loop_from_thread_pool;
|
||||
tp_cfg.wake_loop_ctx = NULL;
|
||||
|
||||
if (thread_pool_init(&tp_cfg) == 0) {
|
||||
thread_pool_initialized = 1;
|
||||
DEBUG_INFO("Thread pool scaffold enabled (%d readers)", tp_cfg.reader_threads);
|
||||
} else {
|
||||
DEBUG_WARN("Failed to initialize thread pool scaffold; continuing in synchronous mode");
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
|
||||
if (thread_pool_initialized) {
|
||||
thread_pool_shutdown();
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
cleanup_relay_info();
|
||||
ginxsom_request_validator_cleanup();
|
||||
@@ -2185,6 +2308,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();
|
||||
|
||||
|
||||
+4
-4
@@ -11,10 +11,10 @@
|
||||
|
||||
// Version information (auto-updated by build system)
|
||||
// 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 39
|
||||
#define CRELAY_VERSION "v1.2.39"
|
||||
#define CRELAY_VERSION_MAJOR 2
|
||||
#define CRELAY_VERSION_MINOR 0
|
||||
#define CRELAY_VERSION_PATCH 0
|
||||
#define CRELAY_VERSION "v2.0.0"
|
||||
|
||||
// Relay metadata (authoritative source for NIP-11 information)
|
||||
#define RELAY_NAME "C-Relay"
|
||||
|
||||
+33
-89
@@ -7,7 +7,7 @@
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
#include <cjson/cJSON.h>
|
||||
#include "debug.h"
|
||||
#include <sqlite3.h>
|
||||
#include "db_ops.h"
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
@@ -21,10 +21,6 @@ int store_event(cJSON* event);
|
||||
int delete_events_by_id(const char* requester_pubkey, cJSON* event_ids);
|
||||
int delete_events_by_address(const char* requester_pubkey, cJSON* addresses, long deletion_timestamp);
|
||||
|
||||
// Global database variable
|
||||
extern sqlite3* g_db;
|
||||
|
||||
|
||||
// Handle NIP-09 deletion request event (kind 5)
|
||||
int handle_deletion_request(cJSON* event, char* error_message, size_t error_size) {
|
||||
if (!event) {
|
||||
@@ -146,97 +142,72 @@ int handle_deletion_request(cJSON* event, char* error_message, size_t error_size
|
||||
|
||||
// Delete events by ID (with pubkey authorization)
|
||||
int delete_events_by_id(const char* requester_pubkey, cJSON* event_ids) {
|
||||
if (!g_db || !requester_pubkey || !event_ids || !cJSON_IsArray(event_ids)) {
|
||||
if (!db_is_available() || !requester_pubkey || !event_ids || !cJSON_IsArray(event_ids)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
int deleted_count = 0;
|
||||
|
||||
|
||||
cJSON* event_id = NULL;
|
||||
cJSON_ArrayForEach(event_id, event_ids) {
|
||||
if (!cJSON_IsString(event_id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
const char* id = cJSON_GetStringValue(event_id);
|
||||
|
||||
// First check if event exists and if requester is authorized
|
||||
const char* check_sql = "SELECT pubkey FROM events WHERE id = ?";
|
||||
sqlite3_stmt* check_stmt;
|
||||
|
||||
int rc = sqlite3_prepare_v2(g_db, check_sql, -1, &check_stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
char event_pubkey[65] = {0};
|
||||
int exists = db_get_event_pubkey(id, event_pubkey, sizeof(event_pubkey));
|
||||
if (exists <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sqlite3_bind_text(check_stmt, 1, id, -1, SQLITE_STATIC);
|
||||
|
||||
if (sqlite3_step(check_stmt) == SQLITE_ROW) {
|
||||
const char* event_pubkey = (char*)sqlite3_column_text(check_stmt, 0);
|
||||
|
||||
// Only delete if the requester is the author
|
||||
if (event_pubkey && strcmp(event_pubkey, requester_pubkey) == 0) {
|
||||
sqlite3_finalize(check_stmt);
|
||||
|
||||
// Delete the event
|
||||
const char* delete_sql = "DELETE FROM events WHERE id = ? AND pubkey = ?";
|
||||
sqlite3_stmt* delete_stmt;
|
||||
|
||||
rc = sqlite3_prepare_v2(g_db, delete_sql, -1, &delete_stmt, NULL);
|
||||
if (rc == SQLITE_OK) {
|
||||
sqlite3_bind_text(delete_stmt, 1, id, -1, SQLITE_STATIC);
|
||||
sqlite3_bind_text(delete_stmt, 2, requester_pubkey, -1, SQLITE_STATIC);
|
||||
|
||||
if (sqlite3_step(delete_stmt) == SQLITE_DONE && sqlite3_changes(g_db) > 0) {
|
||||
deleted_count++;
|
||||
}
|
||||
sqlite3_finalize(delete_stmt);
|
||||
}
|
||||
} else {
|
||||
sqlite3_finalize(check_stmt);
|
||||
char warning_msg[128];
|
||||
snprintf(warning_msg, sizeof(warning_msg), "Unauthorized deletion attempt for event: %.16s...", id);
|
||||
DEBUG_WARN(warning_msg);
|
||||
|
||||
// Only delete if the requester is the author
|
||||
if (strcmp(event_pubkey, requester_pubkey) == 0) {
|
||||
int changes = db_delete_event_by_id(id, requester_pubkey);
|
||||
if (changes > 0) {
|
||||
deleted_count += changes;
|
||||
}
|
||||
} else {
|
||||
sqlite3_finalize(check_stmt);
|
||||
char warning_msg[128];
|
||||
snprintf(warning_msg, sizeof(warning_msg), "Unauthorized deletion attempt for event: %.16s...", id);
|
||||
DEBUG_WARN(warning_msg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return deleted_count;
|
||||
}
|
||||
|
||||
// Delete events by addressable reference (kind:pubkey:d-identifier)
|
||||
int delete_events_by_address(const char* requester_pubkey, cJSON* addresses, long deletion_timestamp) {
|
||||
if (!g_db || !requester_pubkey || !addresses || !cJSON_IsArray(addresses)) {
|
||||
if (!db_is_available() || !requester_pubkey || !addresses || !cJSON_IsArray(addresses)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
int deleted_count = 0;
|
||||
|
||||
|
||||
cJSON* address = NULL;
|
||||
cJSON_ArrayForEach(address, addresses) {
|
||||
if (!cJSON_IsString(address)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
const char* addr = cJSON_GetStringValue(address);
|
||||
|
||||
|
||||
// Parse address format: kind:pubkey:d-identifier
|
||||
char* addr_copy = strdup(addr);
|
||||
if (!addr_copy) continue;
|
||||
|
||||
|
||||
char* kind_str = strtok(addr_copy, ":");
|
||||
char* pubkey_str = strtok(NULL, ":");
|
||||
char* d_identifier = strtok(NULL, ":");
|
||||
|
||||
|
||||
if (!kind_str || !pubkey_str) {
|
||||
free(addr_copy);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
int kind = atoi(kind_str);
|
||||
|
||||
|
||||
// Only delete if the requester is the author
|
||||
if (strcmp(pubkey_str, requester_pubkey) != 0) {
|
||||
free(addr_copy);
|
||||
@@ -245,42 +216,15 @@ int delete_events_by_address(const char* requester_pubkey, cJSON* addresses, lon
|
||||
DEBUG_WARN(warning_msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build deletion query based on whether we have d-identifier
|
||||
const char* delete_sql;
|
||||
sqlite3_stmt* delete_stmt;
|
||||
|
||||
if (d_identifier && strlen(d_identifier) > 0) {
|
||||
// Delete specific addressable event with d-tag
|
||||
delete_sql = "DELETE FROM events WHERE kind = ? AND pubkey = ? AND created_at <= ? "
|
||||
"AND json_extract(tags, '$[*]') LIKE '%[\"d\",\"' || ? || '\"]%'";
|
||||
} else {
|
||||
// Delete all events of this kind by this author up to deletion timestamp
|
||||
delete_sql = "DELETE FROM events WHERE kind = ? AND pubkey = ? AND created_at <= ?";
|
||||
|
||||
int changes = db_delete_events_by_address(requester_pubkey, kind, d_identifier, deletion_timestamp);
|
||||
if (changes > 0) {
|
||||
deleted_count += changes;
|
||||
}
|
||||
|
||||
int rc = sqlite3_prepare_v2(g_db, delete_sql, -1, &delete_stmt, NULL);
|
||||
if (rc == SQLITE_OK) {
|
||||
sqlite3_bind_int(delete_stmt, 1, kind);
|
||||
sqlite3_bind_text(delete_stmt, 2, requester_pubkey, -1, SQLITE_STATIC);
|
||||
sqlite3_bind_int64(delete_stmt, 3, deletion_timestamp);
|
||||
|
||||
if (d_identifier && strlen(d_identifier) > 0) {
|
||||
sqlite3_bind_text(delete_stmt, 4, d_identifier, -1, SQLITE_STATIC);
|
||||
}
|
||||
|
||||
if (sqlite3_step(delete_stmt) == SQLITE_DONE) {
|
||||
int changes = sqlite3_changes(g_db);
|
||||
if (changes > 0) {
|
||||
deleted_count += changes;
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(delete_stmt);
|
||||
}
|
||||
|
||||
|
||||
free(addr_copy);
|
||||
}
|
||||
|
||||
|
||||
return deleted_count;
|
||||
}
|
||||
|
||||
|
||||
+11
-1
@@ -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) {
|
||||
|
||||
+20
-94
@@ -17,17 +17,13 @@
|
||||
#include "../nostr_core_lib/nostr_core/utils.h"
|
||||
#include "debug.h" // C-relay debug system
|
||||
#include "config.h" // C-relay configuration system
|
||||
#include <sqlite3.h>
|
||||
#include "db_ops.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <time.h>
|
||||
|
||||
// External references to C-relay global state
|
||||
extern sqlite3* g_db;
|
||||
extern char g_database_path[512];
|
||||
|
||||
// Forward declaration for C-relay event storage function
|
||||
extern int store_event(cJSON* event);
|
||||
|
||||
@@ -304,9 +300,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
|
||||
}
|
||||
|
||||
@@ -528,112 +526,40 @@ static int reload_auth_config(void) {
|
||||
*/
|
||||
int check_database_auth_rules(const char *pubkey, const char *operation __attribute__((unused)),
|
||||
const char *resource_hash) {
|
||||
sqlite3 *db = NULL;
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc;
|
||||
|
||||
DEBUG_TRACE("Checking auth rules for pubkey: %s", pubkey);
|
||||
|
||||
if (!pubkey) {
|
||||
return NOSTR_ERROR_INVALID_INPUT;
|
||||
}
|
||||
|
||||
// Open database using global database path
|
||||
if (strlen(g_database_path) == 0) {
|
||||
return NOSTR_SUCCESS; // Default allow on DB error
|
||||
}
|
||||
|
||||
rc = sqlite3_open_v2(g_database_path, &db, SQLITE_OPEN_READONLY, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
return NOSTR_SUCCESS; // Default allow on DB error
|
||||
}
|
||||
|
||||
// Step 1: Check pubkey blacklist (highest priority)
|
||||
const char *blacklist_sql =
|
||||
"SELECT rule_type FROM auth_rules WHERE rule_type = "
|
||||
"'blacklist' AND pattern_type = 'pubkey' AND pattern_value = ? AND active = 1 LIMIT 1";
|
||||
DEBUG_TRACE("Blacklist SQL: %s", blacklist_sql);
|
||||
rc = sqlite3_prepare_v2(db, blacklist_sql, -1, &stmt, NULL);
|
||||
if (rc == SQLITE_OK) {
|
||||
sqlite3_bind_text(stmt, 1, pubkey, -1, SQLITE_STATIC);
|
||||
|
||||
int step_result = sqlite3_step(stmt);
|
||||
DEBUG_TRACE("Blacklist query result: %s", step_result == SQLITE_ROW ? "FOUND" : "NOT_FOUND");
|
||||
|
||||
if (step_result == SQLITE_ROW) {
|
||||
DEBUG_TRACE("BLACKLIST HIT: Denying access for pubkey: %s", pubkey);
|
||||
// Set specific violation details for status code mapping
|
||||
strcpy(g_last_rule_violation.violation_type, "pubkey_blacklist");
|
||||
sprintf(g_last_rule_violation.reason, "Public key blacklisted");
|
||||
|
||||
sqlite3_finalize(stmt);
|
||||
sqlite3_close(db);
|
||||
return NOSTR_ERROR_AUTH_REQUIRED;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
if (db_is_pubkey_blacklisted(pubkey)) {
|
||||
DEBUG_TRACE("BLACKLIST HIT: Denying access for pubkey: %s", pubkey);
|
||||
strcpy(g_last_rule_violation.violation_type, "pubkey_blacklist");
|
||||
sprintf(g_last_rule_violation.reason, "Public key blacklisted");
|
||||
return NOSTR_ERROR_AUTH_REQUIRED;
|
||||
}
|
||||
|
||||
// Step 2: Check hash blacklist
|
||||
if (resource_hash) {
|
||||
const char *hash_blacklist_sql =
|
||||
"SELECT rule_type FROM auth_rules WHERE rule_type = "
|
||||
"'blacklist' AND pattern_type = 'hash' AND pattern_value = ? AND active = 1 LIMIT 1";
|
||||
rc = sqlite3_prepare_v2(db, hash_blacklist_sql, -1, &stmt, NULL);
|
||||
if (rc == SQLITE_OK) {
|
||||
sqlite3_bind_text(stmt, 1, resource_hash, -1, SQLITE_STATIC);
|
||||
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
// Set specific violation details for status code mapping
|
||||
strcpy(g_last_rule_violation.violation_type, "hash_blacklist");
|
||||
sprintf(g_last_rule_violation.reason, "File hash blacklisted");
|
||||
|
||||
sqlite3_finalize(stmt);
|
||||
sqlite3_close(db);
|
||||
return NOSTR_ERROR_AUTH_REQUIRED;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
if (resource_hash && db_is_hash_blacklisted(resource_hash)) {
|
||||
strcpy(g_last_rule_violation.violation_type, "hash_blacklist");
|
||||
sprintf(g_last_rule_violation.reason, "File hash blacklisted");
|
||||
return NOSTR_ERROR_AUTH_REQUIRED;
|
||||
}
|
||||
|
||||
// Step 3: Check pubkey whitelist (includes wot_whitelist)
|
||||
const char *whitelist_sql =
|
||||
"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);
|
||||
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
sqlite3_finalize(stmt);
|
||||
sqlite3_close(db);
|
||||
return NOSTR_SUCCESS; // Allow whitelisted pubkey
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
if (db_is_pubkey_whitelisted(pubkey)) {
|
||||
return NOSTR_SUCCESS; // Allow whitelisted pubkey
|
||||
}
|
||||
|
||||
// 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 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) {
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
int whitelist_count = sqlite3_column_int(stmt, 0);
|
||||
if (whitelist_count > 0) {
|
||||
// Set specific violation details for status code mapping
|
||||
strcpy(g_last_rule_violation.violation_type, "whitelist_violation");
|
||||
strcpy(g_last_rule_violation.reason,
|
||||
"Public key not whitelisted for this operation");
|
||||
|
||||
sqlite3_finalize(stmt);
|
||||
sqlite3_close(db);
|
||||
return NOSTR_ERROR_AUTH_REQUIRED;
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
// Step 4: If any whitelist rules exist, deny by default
|
||||
if (db_count_active_whitelist_rules() > 0) {
|
||||
strcpy(g_last_rule_violation.violation_type, "whitelist_violation");
|
||||
strcpy(g_last_rule_violation.reason,
|
||||
"Public key not whitelisted for this operation");
|
||||
return NOSTR_ERROR_AUTH_REQUIRED;
|
||||
}
|
||||
|
||||
sqlite3_close(db);
|
||||
return NOSTR_SUCCESS; // Default allow if no restrictive rules matched
|
||||
}
|
||||
|
||||
|
||||
+25
-124
@@ -1,7 +1,7 @@
|
||||
#define _GNU_SOURCE
|
||||
#include <cjson/cJSON.h>
|
||||
#include "debug.h"
|
||||
#include <sqlite3.h>
|
||||
#include "db_ops.h"
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
@@ -28,9 +28,6 @@ int validate_search_term(const char* search_term, char* error_message, size_t er
|
||||
// Forward declaration for monitoring function
|
||||
void monitoring_on_subscription_change(void);
|
||||
|
||||
// Global database variable
|
||||
extern sqlite3* g_db;
|
||||
|
||||
// Configuration functions from config.c
|
||||
extern int get_config_bool(const char* key, int default_value);
|
||||
|
||||
@@ -991,21 +988,21 @@ int has_subscriptions_for_kind(int event_kind) {
|
||||
|
||||
// Log subscription creation to database
|
||||
void log_subscription_created(const subscription_t* sub) {
|
||||
if (!g_db || !sub) return;
|
||||
|
||||
if (!sub) return;
|
||||
|
||||
// Convert wsi pointer to string
|
||||
char wsi_str[32];
|
||||
snprintf(wsi_str, sizeof(wsi_str), "%p", (void*)sub->wsi);
|
||||
|
||||
|
||||
// Create filter JSON for logging
|
||||
char* filter_json = NULL;
|
||||
if (sub->filters) {
|
||||
cJSON* filters_array = cJSON_CreateArray();
|
||||
subscription_filter_t* filter = sub->filters;
|
||||
|
||||
|
||||
while (filter) {
|
||||
cJSON* filter_obj = cJSON_CreateObject();
|
||||
|
||||
|
||||
if (filter->kinds) {
|
||||
cJSON_AddItemToObject(filter_obj, "kinds", cJSON_Duplicate(filter->kinds, 1));
|
||||
}
|
||||
@@ -1034,100 +1031,33 @@ void log_subscription_created(const subscription_t* sub) {
|
||||
}
|
||||
cJSON_Delete(tags_obj);
|
||||
}
|
||||
|
||||
|
||||
cJSON_AddItemToArray(filters_array, filter_obj);
|
||||
filter = filter->next;
|
||||
}
|
||||
|
||||
|
||||
filter_json = cJSON_Print(filters_array);
|
||||
cJSON_Delete(filters_array);
|
||||
}
|
||||
|
||||
// Use INSERT OR REPLACE to handle duplicates automatically
|
||||
const char* sql =
|
||||
"INSERT OR REPLACE INTO subscriptions (subscription_id, wsi_pointer, client_ip, event_type, filter_json) "
|
||||
"VALUES (?, ?, ?, 'created', ?)";
|
||||
|
||||
sqlite3_stmt* stmt;
|
||||
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
|
||||
if (rc == SQLITE_OK) {
|
||||
sqlite3_bind_text(stmt, 1, sub->id, -1, SQLITE_STATIC);
|
||||
sqlite3_bind_text(stmt, 2, wsi_str, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, sub->client_ip, -1, SQLITE_STATIC);
|
||||
sqlite3_bind_text(stmt, 4, filter_json ? filter_json : "[]", -1, SQLITE_TRANSIENT);
|
||||
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
|
||||
db_log_subscription_created(sub->id, wsi_str, sub->client_ip, filter_json ? filter_json : "[]");
|
||||
|
||||
if (filter_json) free(filter_json);
|
||||
}
|
||||
|
||||
// Log subscription closure to database
|
||||
void log_subscription_closed(const char* sub_id, const char* client_ip, const char* reason) {
|
||||
(void)reason; // Mark as intentionally unused
|
||||
if (!g_db || !sub_id) return;
|
||||
|
||||
const char* sql =
|
||||
"INSERT INTO subscriptions (subscription_id, wsi_pointer, client_ip, event_type) "
|
||||
"VALUES (?, '', ?, 'closed')";
|
||||
|
||||
sqlite3_stmt* stmt;
|
||||
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
|
||||
if (rc == SQLITE_OK) {
|
||||
sqlite3_bind_text(stmt, 1, sub_id, -1, SQLITE_STATIC);
|
||||
sqlite3_bind_text(stmt, 2, client_ip ? client_ip : "unknown", -1, SQLITE_STATIC);
|
||||
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
// Update the corresponding 'created' entry with end time and events sent
|
||||
const char* update_sql =
|
||||
"UPDATE subscriptions "
|
||||
"SET ended_at = strftime('%s', 'now') "
|
||||
"WHERE subscription_id = ? AND event_type = 'created' AND ended_at IS NULL";
|
||||
|
||||
rc = sqlite3_prepare_v2(g_db, update_sql, -1, &stmt, NULL);
|
||||
if (rc == SQLITE_OK) {
|
||||
sqlite3_bind_text(stmt, 1, sub_id, -1, SQLITE_STATIC);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
if (!sub_id) return;
|
||||
|
||||
db_log_subscription_closed(sub_id, client_ip);
|
||||
}
|
||||
|
||||
// Log subscription disconnection to database
|
||||
void log_subscription_disconnected(const char* client_ip) {
|
||||
if (!g_db || !client_ip) return;
|
||||
|
||||
// Mark all active subscriptions for this client as disconnected
|
||||
const char* sql =
|
||||
"UPDATE subscriptions "
|
||||
"SET ended_at = strftime('%s', 'now') "
|
||||
"WHERE client_ip = ? AND event_type = 'created' AND ended_at IS NULL";
|
||||
|
||||
sqlite3_stmt* stmt;
|
||||
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
|
||||
if (rc == SQLITE_OK) {
|
||||
sqlite3_bind_text(stmt, 1, client_ip, -1, SQLITE_STATIC);
|
||||
int changes = sqlite3_changes(g_db);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (changes > 0) {
|
||||
// Log a disconnection event
|
||||
const char* insert_sql =
|
||||
"INSERT INTO subscriptions (subscription_id, wsi_pointer, client_ip, event_type) "
|
||||
"VALUES ('disconnect', '', ?, 'disconnected')";
|
||||
|
||||
rc = sqlite3_prepare_v2(g_db, insert_sql, -1, &stmt, NULL);
|
||||
if (rc == SQLITE_OK) {
|
||||
sqlite3_bind_text(stmt, 1, client_ip, -1, SQLITE_STATIC);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!client_ip) return;
|
||||
|
||||
db_log_subscription_disconnected(client_ip);
|
||||
}
|
||||
|
||||
// Log event broadcast to database (optional, can be resource intensive)
|
||||
@@ -1153,55 +1083,26 @@ void log_subscription_disconnected(const char* client_ip) {
|
||||
|
||||
// Update events sent counter for a subscription
|
||||
void update_subscription_events_sent(const char* sub_id, int events_sent) {
|
||||
if (!g_db || !sub_id) return;
|
||||
if (!sub_id) return;
|
||||
|
||||
const char* sql =
|
||||
"UPDATE subscriptions "
|
||||
"SET events_sent = ? "
|
||||
"WHERE subscription_id = ? AND event_type = 'created'";
|
||||
|
||||
sqlite3_stmt* stmt;
|
||||
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
|
||||
if (rc == SQLITE_OK) {
|
||||
sqlite3_bind_int(stmt, 1, events_sent);
|
||||
sqlite3_bind_text(stmt, 2, sub_id, -1, SQLITE_STATIC);
|
||||
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
db_update_subscription_events_sent(sub_id, events_sent);
|
||||
}
|
||||
|
||||
// Cleanup all subscriptions on startup
|
||||
void cleanup_all_subscriptions_on_startup(void) {
|
||||
if (!g_db) {
|
||||
if (!db_is_available()) {
|
||||
DEBUG_ERROR("Database not available for startup cleanup");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
DEBUG_LOG("Performing startup subscription cleanup");
|
||||
|
||||
// Mark all active subscriptions as disconnected
|
||||
const char* sql =
|
||||
"UPDATE subscriptions "
|
||||
"SET ended_at = strftime('%s', 'now') "
|
||||
"WHERE event_type = 'created' AND ended_at IS NULL";
|
||||
|
||||
sqlite3_stmt* stmt;
|
||||
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
DEBUG_ERROR("Failed to prepare startup cleanup query");
|
||||
return;
|
||||
}
|
||||
|
||||
rc = sqlite3_step(stmt);
|
||||
int changes = sqlite3_changes(g_db);
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (rc != SQLITE_DONE) {
|
||||
|
||||
int changes = db_cleanup_orphaned_subscriptions();
|
||||
if (changes < 0) {
|
||||
DEBUG_ERROR("Failed to execute startup cleanup");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (changes > 0) {
|
||||
DEBUG_LOG("Startup cleanup: marked %d orphaned subscriptions as disconnected", changes);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
#define _GNU_SOURCE
|
||||
|
||||
#include "thread_pool.h"
|
||||
#include "debug.h"
|
||||
|
||||
#include <pthread.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef struct thread_pool_job_node {
|
||||
uint64_t job_id;
|
||||
thread_pool_job_t job;
|
||||
struct thread_pool_job_node* next;
|
||||
} thread_pool_job_node_t;
|
||||
|
||||
typedef struct {
|
||||
thread_pool_job_node_t* head;
|
||||
thread_pool_job_node_t* tail;
|
||||
int size;
|
||||
int max_size;
|
||||
pthread_mutex_t mutex;
|
||||
pthread_cond_t cond;
|
||||
} thread_pool_queue_t;
|
||||
|
||||
typedef struct {
|
||||
int running;
|
||||
uint64_t next_job_id;
|
||||
|
||||
int reader_count;
|
||||
pthread_t* readers;
|
||||
pthread_t writer;
|
||||
|
||||
thread_pool_queue_t read_q;
|
||||
thread_pool_queue_t write_q;
|
||||
|
||||
thread_pool_wake_loop_cb wake_loop_cb;
|
||||
void* wake_loop_ctx;
|
||||
|
||||
pthread_mutex_t state_mutex;
|
||||
} thread_pool_state_t;
|
||||
|
||||
static thread_pool_state_t g_pool = {0};
|
||||
|
||||
static void queue_init(thread_pool_queue_t* q, int max_size) {
|
||||
memset(q, 0, sizeof(*q));
|
||||
q->max_size = (max_size > 0) ? max_size : 4096;
|
||||
pthread_mutex_init(&q->mutex, NULL);
|
||||
pthread_cond_init(&q->cond, NULL);
|
||||
}
|
||||
|
||||
static void queue_destroy(thread_pool_queue_t* q) {
|
||||
pthread_mutex_lock(&q->mutex);
|
||||
thread_pool_job_node_t* cur = q->head;
|
||||
while (cur) {
|
||||
thread_pool_job_node_t* next = cur->next;
|
||||
if (cur->job.payload_free && cur->job.payload) {
|
||||
cur->job.payload_free(cur->job.payload);
|
||||
}
|
||||
free(cur);
|
||||
cur = next;
|
||||
}
|
||||
q->head = q->tail = NULL;
|
||||
q->size = 0;
|
||||
pthread_mutex_unlock(&q->mutex);
|
||||
|
||||
pthread_mutex_destroy(&q->mutex);
|
||||
pthread_cond_destroy(&q->cond);
|
||||
}
|
||||
|
||||
static int queue_push(thread_pool_queue_t* q, thread_pool_job_node_t* node) {
|
||||
pthread_mutex_lock(&q->mutex);
|
||||
if (q->size >= q->max_size) {
|
||||
pthread_mutex_unlock(&q->mutex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
node->next = NULL;
|
||||
if (!q->tail) {
|
||||
q->head = q->tail = node;
|
||||
} else {
|
||||
q->tail->next = node;
|
||||
q->tail = node;
|
||||
}
|
||||
q->size++;
|
||||
pthread_cond_signal(&q->cond);
|
||||
pthread_mutex_unlock(&q->mutex);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static thread_pool_job_node_t* queue_pop(thread_pool_queue_t* q) {
|
||||
pthread_mutex_lock(&q->mutex);
|
||||
while (g_pool.running && q->size == 0) {
|
||||
pthread_cond_wait(&q->cond, &q->mutex);
|
||||
}
|
||||
|
||||
if (!g_pool.running && q->size == 0) {
|
||||
pthread_mutex_unlock(&q->mutex);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
thread_pool_job_node_t* node = q->head;
|
||||
q->head = node->next;
|
||||
if (!q->head) q->tail = NULL;
|
||||
q->size--;
|
||||
pthread_mutex_unlock(&q->mutex);
|
||||
return node;
|
||||
}
|
||||
|
||||
static void wake_event_loop(void) {
|
||||
if (g_pool.wake_loop_cb) {
|
||||
g_pool.wake_loop_cb(g_pool.wake_loop_ctx);
|
||||
}
|
||||
}
|
||||
|
||||
static void complete_job_with_status(thread_pool_job_node_t* node, thread_pool_status_t status, const char* message) {
|
||||
thread_pool_result_t result;
|
||||
memset(&result, 0, sizeof(result));
|
||||
result.job_id = node->job_id;
|
||||
result.type = node->job.type;
|
||||
result.status = status;
|
||||
result.session = node->job.session;
|
||||
result.message = message;
|
||||
|
||||
if (node->job.result_cb) {
|
||||
node->job.result_cb(&result, node->job.result_cb_ctx);
|
||||
}
|
||||
wake_event_loop();
|
||||
|
||||
if (node->job.payload_free && node->job.payload) {
|
||||
node->job.payload_free(node->job.payload);
|
||||
}
|
||||
free(node);
|
||||
}
|
||||
|
||||
static void* reader_worker_main(void* arg) {
|
||||
(void)arg;
|
||||
while (g_pool.running) {
|
||||
thread_pool_job_node_t* node = queue_pop(&g_pool.read_q);
|
||||
if (!node) break;
|
||||
|
||||
// Scaffold only: execution wiring is intentionally deferred.
|
||||
complete_job_with_status(node, THREAD_POOL_STATUS_NOT_IMPLEMENTED,
|
||||
"Read worker scaffold active; execution not wired yet");
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void* writer_worker_main(void* arg) {
|
||||
(void)arg;
|
||||
while (g_pool.running) {
|
||||
thread_pool_job_node_t* node = queue_pop(&g_pool.write_q);
|
||||
if (!node) break;
|
||||
|
||||
// Scaffold only: execution wiring is intentionally deferred.
|
||||
complete_job_with_status(node, THREAD_POOL_STATUS_NOT_IMPLEMENTED,
|
||||
"Write worker scaffold active; execution not wired yet");
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int thread_pool_init(const thread_pool_config_t* config) {
|
||||
if (!config) return -1;
|
||||
|
||||
pthread_mutex_lock(&g_pool.state_mutex);
|
||||
if (g_pool.running) {
|
||||
pthread_mutex_unlock(&g_pool.state_mutex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
g_pool.reader_count = (config->reader_threads > 0) ? config->reader_threads : 4;
|
||||
g_pool.readers = calloc((size_t)g_pool.reader_count, sizeof(pthread_t));
|
||||
if (!g_pool.readers) {
|
||||
pthread_mutex_unlock(&g_pool.state_mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
queue_init(&g_pool.read_q, config->max_queue_depth);
|
||||
queue_init(&g_pool.write_q, config->max_queue_depth);
|
||||
|
||||
g_pool.next_job_id = 1;
|
||||
g_pool.wake_loop_cb = config->wake_loop_cb;
|
||||
g_pool.wake_loop_ctx = config->wake_loop_ctx;
|
||||
g_pool.running = 1;
|
||||
|
||||
for (int i = 0; i < g_pool.reader_count; i++) {
|
||||
if (pthread_create(&g_pool.readers[i], NULL, reader_worker_main, NULL) != 0) {
|
||||
g_pool.running = 0;
|
||||
pthread_cond_broadcast(&g_pool.read_q.cond);
|
||||
pthread_cond_broadcast(&g_pool.write_q.cond);
|
||||
pthread_mutex_unlock(&g_pool.state_mutex);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (pthread_create(&g_pool.writer, NULL, writer_worker_main, NULL) != 0) {
|
||||
g_pool.running = 0;
|
||||
pthread_cond_broadcast(&g_pool.read_q.cond);
|
||||
pthread_cond_broadcast(&g_pool.write_q.cond);
|
||||
pthread_mutex_unlock(&g_pool.state_mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&g_pool.state_mutex);
|
||||
DEBUG_LOG("Thread pool initialized: %d readers + 1 writer", g_pool.reader_count);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void thread_pool_shutdown(void) {
|
||||
pthread_mutex_lock(&g_pool.state_mutex);
|
||||
if (!g_pool.running) {
|
||||
pthread_mutex_unlock(&g_pool.state_mutex);
|
||||
return;
|
||||
}
|
||||
|
||||
g_pool.running = 0;
|
||||
pthread_cond_broadcast(&g_pool.read_q.cond);
|
||||
pthread_cond_broadcast(&g_pool.write_q.cond);
|
||||
pthread_mutex_unlock(&g_pool.state_mutex);
|
||||
|
||||
for (int i = 0; i < g_pool.reader_count; i++) {
|
||||
pthread_join(g_pool.readers[i], NULL);
|
||||
}
|
||||
pthread_join(g_pool.writer, NULL);
|
||||
|
||||
free(g_pool.readers);
|
||||
g_pool.readers = NULL;
|
||||
g_pool.reader_count = 0;
|
||||
|
||||
queue_destroy(&g_pool.read_q);
|
||||
queue_destroy(&g_pool.write_q);
|
||||
|
||||
DEBUG_LOG("Thread pool shutdown complete");
|
||||
}
|
||||
|
||||
int thread_pool_is_running(void) {
|
||||
return g_pool.running;
|
||||
}
|
||||
|
||||
static thread_pool_status_t submit_to_queue(thread_pool_queue_t* q, const thread_pool_job_t* job, uint64_t* out_job_id) {
|
||||
if (!g_pool.running) return THREAD_POOL_STATUS_SHUTTING_DOWN;
|
||||
if (!job) return THREAD_POOL_STATUS_INTERNAL_ERROR;
|
||||
|
||||
thread_pool_job_node_t* node = calloc(1, sizeof(*node));
|
||||
if (!node) return THREAD_POOL_STATUS_INTERNAL_ERROR;
|
||||
|
||||
node->job = *job;
|
||||
|
||||
pthread_mutex_lock(&g_pool.state_mutex);
|
||||
node->job_id = g_pool.next_job_id++;
|
||||
pthread_mutex_unlock(&g_pool.state_mutex);
|
||||
|
||||
if (out_job_id) *out_job_id = node->job_id;
|
||||
|
||||
if (!queue_push(q, node)) {
|
||||
if (job->payload_free && job->payload) {
|
||||
job->payload_free(job->payload);
|
||||
}
|
||||
free(node);
|
||||
return THREAD_POOL_STATUS_QUEUE_FULL;
|
||||
}
|
||||
|
||||
return THREAD_POOL_STATUS_OK;
|
||||
}
|
||||
|
||||
thread_pool_status_t thread_pool_submit_read(const thread_pool_job_t* job, uint64_t* out_job_id) {
|
||||
return submit_to_queue(&g_pool.read_q, job, out_job_id);
|
||||
}
|
||||
|
||||
thread_pool_status_t thread_pool_submit_write(const thread_pool_job_t* job, uint64_t* out_job_id) {
|
||||
return submit_to_queue(&g_pool.write_q, job, out_job_id);
|
||||
}
|
||||
|
||||
__attribute__((constructor))
|
||||
static void thread_pool_state_init_once(void) {
|
||||
pthread_mutex_init(&g_pool.state_mutex, NULL);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
#ifndef THREAD_POOL_H
|
||||
#define THREAD_POOL_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef enum {
|
||||
THREAD_POOL_JOB_REQ_QUERY = 0,
|
||||
THREAD_POOL_JOB_COUNT_QUERY,
|
||||
THREAD_POOL_JOB_STORE_EVENT,
|
||||
THREAD_POOL_JOB_DELETE_EVENT,
|
||||
THREAD_POOL_JOB_CUSTOM
|
||||
} thread_pool_job_type_t;
|
||||
|
||||
typedef enum {
|
||||
THREAD_POOL_STATUS_OK = 0,
|
||||
THREAD_POOL_STATUS_NOT_IMPLEMENTED,
|
||||
THREAD_POOL_STATUS_QUEUE_FULL,
|
||||
THREAD_POOL_STATUS_SHUTTING_DOWN,
|
||||
THREAD_POOL_STATUS_INTERNAL_ERROR
|
||||
} thread_pool_status_t;
|
||||
|
||||
typedef struct {
|
||||
uint64_t job_id;
|
||||
thread_pool_job_type_t type;
|
||||
thread_pool_status_t status;
|
||||
void* session;
|
||||
void* result_data;
|
||||
size_t result_size;
|
||||
const char* message;
|
||||
} thread_pool_result_t;
|
||||
|
||||
typedef void (*thread_pool_result_cb)(const thread_pool_result_t* result, void* user_ctx);
|
||||
typedef void (*thread_pool_payload_free_cb)(void* payload);
|
||||
typedef void (*thread_pool_wake_loop_cb)(void* wake_ctx);
|
||||
|
||||
typedef struct {
|
||||
thread_pool_job_type_t type;
|
||||
void* session;
|
||||
void* payload;
|
||||
size_t payload_size;
|
||||
thread_pool_payload_free_cb payload_free;
|
||||
thread_pool_result_cb result_cb;
|
||||
void* result_cb_ctx;
|
||||
} thread_pool_job_t;
|
||||
|
||||
typedef struct {
|
||||
int reader_threads;
|
||||
int max_queue_depth;
|
||||
const char* db_path;
|
||||
thread_pool_wake_loop_cb wake_loop_cb;
|
||||
void* wake_loop_ctx;
|
||||
} thread_pool_config_t;
|
||||
|
||||
int thread_pool_init(const thread_pool_config_t* config);
|
||||
void thread_pool_shutdown(void);
|
||||
int thread_pool_is_running(void);
|
||||
|
||||
thread_pool_status_t thread_pool_submit_read(const thread_pool_job_t* job, uint64_t* out_job_id);
|
||||
thread_pool_status_t thread_pool_submit_write(const thread_pool_job_t* job, uint64_t* out_job_id);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif // THREAD_POOL_H
|
||||
+20
-4
@@ -31,6 +31,7 @@
|
||||
#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
|
||||
#include "thread_pool.h" // Thread pool scaffold
|
||||
|
||||
// Forward declarations for logging functions
|
||||
|
||||
@@ -116,6 +117,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 +134,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 +1541,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
||||
}
|
||||
}
|
||||
}
|
||||
free((char*)relay_pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1547,6 +1554,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) {
|
||||
@@ -2447,8 +2455,7 @@ static void check_connection_age(int max_connection_seconds) {
|
||||
|
||||
// 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);
|
||||
ip_ban_log_stats();
|
||||
}
|
||||
|
||||
// WebSocket protocol definition
|
||||
@@ -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),
|
||||
@@ -2666,8 +2676,7 @@ int start_websocket_relay(int port_override, int strict_port) {
|
||||
} 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);
|
||||
ip_ban_log_stats();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2716,6 +2725,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 +2744,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();
|
||||
@@ -2850,6 +2862,10 @@ int process_dm_stats_command(cJSON* dm_event, char* error_message, size_t error_
|
||||
int handle_count_message(const char* sub_id, cJSON* filters, struct lws *wsi, struct per_session_data *pss) {
|
||||
// pss is now used for query tracking, so remove unused warning suppression
|
||||
|
||||
if (thread_pool_is_running()) {
|
||||
DEBUG_TRACE("Thread pool scaffold active: handle_count_message() still using synchronous DB path");
|
||||
}
|
||||
|
||||
if (!cJSON_IsArray(filters)) {
|
||||
DEBUG_ERROR("COUNT filters is not an array");
|
||||
return 0;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user