Compare commits

..

6 Commits

Author SHA1 Message Date
Your Name
e312d7e18c v0.7.25 - Implement SQL Query Admin API
- Move non-NIP-17 admin functions from dm_admin.c to api.c for better architecture
- Add NIP-44 encryption to send_admin_response() for secure admin responses
- Implement SQL query validation and execution with safety limits
- Add unified SQL query handler for admin API
- Fix buffer size for encrypted content to handle larger responses
- Update function declarations and includes across files
- Successfully test frontend query execution through web interface
2025-10-16 15:41:21 -04:00
Your Name
6c38aaebf3 v0.7.24 - Fix admin API subscription issues: NIP-17 historical events and relay pubkey timing 2025-10-16 06:27:01 -04:00
Your Name
18b0ac44bf v0.7.23 - Remove sticky positioning from main header to prevent floating behavior 2025-10-15 19:22:42 -04:00
Your Name
b6749eff2f v0.7.22 - Fix compiler warnings in c_utils_lib version.c by adding proper includes for popen/pclose functions 2025-10-15 19:20:14 -04:00
Your Name
c73a103280 v0.7.21 - Remove manual relay connection UI and implement automatic background connection with seamless data loading 2025-10-15 16:50:22 -04:00
Your Name
a5d194f730 v0.7.20 - Fix automatic relay connection for restored authentication state 2025-10-15 15:47:02 -04:00
19 changed files with 4149 additions and 1691 deletions

View File

@@ -1,6 +1,8 @@
# Alpine-based MUSL static binary builder for C-Relay
# Produces truly portable binaries with zero runtime dependencies
ARG DEBUG_BUILD=false
FROM alpine:3.19 AS builder
# Install build dependencies
@@ -98,9 +100,19 @@ RUN cd nostr_core_lib && \
COPY src/ /build/src/
COPY Makefile /build/Makefile
# Build c-relay with full static linking and debug symbols (only rebuilds when src/ changes)
# Build c-relay with full static linking (only rebuilds when src/ changes)
# Disable fortification to avoid __*_chk symbols that don't exist in MUSL
RUN gcc -static -g -O0 -DDEBUG -Wall -Wextra -std=c99 \
# Use conditional compilation flags based on DEBUG_BUILD argument
RUN if [ "$DEBUG_BUILD" = "true" ]; then \
CFLAGS="-g -O0 -DDEBUG"; \
STRIP_CMD=""; \
echo "Building with DEBUG symbols enabled"; \
else \
CFLAGS="-O2"; \
STRIP_CMD="strip /build/c_relay_static"; \
echo "Building optimized production binary"; \
fi && \
gcc -static $CFLAGS -Wall -Wextra -std=c99 \
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
-I. -Ic_utils_lib/src -Inostr_core_lib -Inostr_core_lib/nostr_core \
-Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \
@@ -111,10 +123,8 @@ RUN gcc -static -g -O0 -DDEBUG -Wall -Wextra -std=c99 \
c_utils_lib/libc_utils.a \
nostr_core_lib/libnostr_core_x64.a \
-lwebsockets -lssl -lcrypto -lsqlite3 -lsecp256k1 \
-lcurl -lz -lpthread -lm -ldl
# DO NOT strip - we need debug symbols for debugging
# RUN strip /build/c_relay_static
-lcurl -lz -lpthread -lm -ldl && \
eval "$STRIP_CMD"
# Verify it's truly static
RUN echo "=== Binary Information ===" && \

View File

@@ -164,6 +164,8 @@ All commands are sent as NIP-44 encrypted JSON arrays in the event content. The
| `system_clear_auth` | `["system_command", "clear_all_auth_rules"]` | Clear all auth rules |
| `system_status` | `["system_command", "system_status"]` | Get system status |
| `stats_query` | `["stats_query"]` | Get comprehensive database statistics |
| **Database Queries** |
| `sql_query` | `["sql_query", "SELECT * FROM events LIMIT 10"]` | Execute read-only SQL query against relay database |
### Available Configuration Keys
@@ -320,8 +322,68 @@ All admin commands return **signed EVENT responses** via WebSocket following sta
],
"sig": "response_event_signature"
}]
```
**SQL Query Response:**
```json
["EVENT", "temp_sub_id", {
"id": "response_event_id",
"pubkey": "relay_public_key",
"created_at": 1234567890,
"kind": 23457,
"content": "nip44 encrypted:{\"query_type\": \"sql_query\", \"request_id\": \"request_event_id\", \"timestamp\": 1234567890, \"query\": \"SELECT * FROM events LIMIT 10\", \"execution_time_ms\": 45, \"row_count\": 10, \"columns\": [\"id\", \"pubkey\", \"created_at\", \"kind\", \"content\"], \"rows\": [[\"abc123...\", \"def456...\", 1234567890, 1, \"Hello world\"], ...]}",
"tags": [
["p", "admin_public_key"],
["e", "request_event_id"]
],
"sig": "response_event_signature"
}]
```
### SQL Query Command
The `sql_query` command allows administrators to execute read-only SQL queries against the relay database. This provides powerful analytics and debugging capabilities through the admin API.
**Request/Response Correlation:**
- Each response includes the request event ID in both the `tags` array (`["e", "request_event_id"]`) and the decrypted content (`"request_id": "request_event_id"`)
- This allows proper correlation when multiple queries are submitted concurrently
- Frontend can track pending queries and match responses to requests
**Security Features:**
- Only SELECT statements allowed (INSERT, UPDATE, DELETE, DROP, etc. are blocked)
- Query timeout: 5 seconds (configurable)
- Result row limit: 1000 rows (configurable)
- All queries logged with execution time
**Available Tables and Views:**
- `events` - All Nostr events
- `config` - Configuration parameters
- `auth_rules` - Authentication rules
- `subscription_events` - Subscription lifecycle log
- `event_broadcasts` - Event broadcast log
- `recent_events` - Last 1000 events (view)
- `event_stats` - Event statistics by type (view)
- `subscription_analytics` - Subscription metrics (view)
- `active_subscriptions_log` - Currently active subscriptions (view)
- `event_kinds_view` - Event distribution by kind (view)
- `top_pubkeys_view` - Top 10 pubkeys by event count (view)
- `time_stats_view` - Time-based statistics (view)
**Example Queries:**
```sql
-- Recent events
SELECT id, pubkey, created_at, kind FROM events ORDER BY created_at DESC LIMIT 20
-- Event distribution by kind
SELECT * FROM event_kinds_view ORDER BY count DESC
-- Active subscriptions
SELECT * FROM active_subscriptions_log ORDER BY created_at DESC
-- Database statistics
SELECT
(SELECT COUNT(*) FROM events) as total_events,
(SELECT COUNT(*) FROM subscription_events) as total_subscriptions
```

View File

@@ -6,7 +6,7 @@
--muted-color: #dddddd;
--border-color: var(--muted-color);
--font-family: "Courier New", Courier, monospace;
--border-radius: 15px;
--border-radius: 5px;
--border-width: 1px;
/* Floating Tab Variables (8) */
@@ -22,6 +22,23 @@
--tab-border-opacity-logged-in: 0.1;
}
/* Dark Mode Overrides */
body.dark-mode {
--primary-color: #ffffff;
--secondary-color: #000000;
--accent-color: #ff0000;
--muted-color: #222222;
--border-color: var(--muted-color);
--tab-bg-logged-out: #000000;
--tab-color-logged-out: #ffffff;
--tab-border-logged-out: #ffffff;
--tab-bg-logged-in: #000000;
--tab-color-logged-in: #ffffff;
--tab-border-logged-in: #00ffff;
}
* {
margin: 0;
padding: 0;
@@ -41,10 +58,8 @@ body {
/* Header Styles */
.main-header {
background-color: var(--secondary-color);
border-bottom: var(--border-width) solid var(--border-color);
padding: 15px 20px;
position: sticky;
top: 0;
z-index: 100;
max-width: 1200px;
margin: 0 auto;
@@ -67,6 +82,86 @@ body {
text-align: left;
}
.relay-info {
text-align: center;
flex: 1;
}
.relay-name {
font-size: 14px;
font-weight: bold;
color: var(--primary-color);
margin-bottom: 2px;
}
.relay-pubkey-container {
border: 1px solid transparent;
border-radius: var(--border-radius);
padding: 4px;
margin-top: 4px;
cursor: pointer;
transition: border-color 0.2s ease;
background-color: var(--secondary-color);
}
.relay-pubkey-container:hover {
border-color: var(--border-color);
}
.relay-pubkey-container.copied {
border-color: var(--accent-color);
animation: flash-accent 0.5s ease-in-out;
}
.relay-pubkey {
font-size: 8px;
color: var(--primary-color);
font-family: "Courier New", Courier, monospace;
line-height: 1.2;
white-space: pre-line;
text-align: center;
}
@keyframes flash-accent {
0% { border-color: var(--accent-color); }
50% { border-color: var(--accent-color); }
100% { border-color: transparent; }
}
.relay-description {
font-size: 10px;
color: var(--primary-color);
margin-bottom: 0;
}
.header-title {
margin: 0;
font-size: 24px;
font-weight: bolder;
color: var(--primary-color);
border: none;
padding: 0;
text-align: left;
display: flex;
gap: 2px;
}
.relay-letter {
position: relative;
display: inline-block;
transition: all 0.05s ease;
}
.relay-letter.underlined::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
right: 0;
height: 2px;
background-color: var(--accent-color);
}
.header-user-name {
display: block;
font-weight: 500;
@@ -78,6 +173,7 @@ body {
.profile-area {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
cursor: pointer;
@@ -87,6 +183,14 @@ body {
margin-left: auto;
}
.admin-label {
font-size: 10px;
color: var(--primary-color);
font-weight: normal;
margin-bottom: 4px;
text-align: center;
}
.profile-container {
display: flex;
flex-direction: column;
@@ -129,13 +233,13 @@ body {
.logout-btn {
width: 100%;
padding: 10px 15px;
padding: 5px 10px;
background: none;
border: none;
color: var(--primary-color);
text-align: left;
cursor: pointer;
font-size: 14px;
font-size: 10px;
font-family: var(--font-family);
border-radius: var(--border-radius);
transition: background-color 0.2s ease;
@@ -255,10 +359,10 @@ button:active {
}
button:disabled {
background-color: #ccc;
color: var(--muted-color);
background-color: var(--muted-color);
color: var(--primary-color);
cursor: not-allowed;
border-color: #ccc;
border-color: var(--muted-color);
}
/* Flash animation for refresh button */
@@ -269,7 +373,7 @@ button:disabled {
}
.flash-red {
animation: flash-red 0.5s ease-in-out;
animation: flash-red 1s ease-in-out;
}
/* Flash animation for updated statistics values */
@@ -280,7 +384,7 @@ button:disabled {
}
.flash-value {
animation: flash-value 0.5s ease-in-out;
animation: flash-value 1s ease-in-out;
}
/* Npub links styling */
@@ -326,23 +430,6 @@ button:disabled {
border-color: var(--accent-color);
}
/* Authentication warning message */
.auth-warning-message {
margin-bottom: 15px;
padding: 12px;
background-color: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: var(--border-radius);
color: #856404;
}
.warning-content {
line-height: 1.4;
}
.warning-content strong {
color: #d68910;
}
.config-table {
border: 1px solid var(--border-color);
@@ -363,6 +450,10 @@ button:disabled {
font-size: 10px;
}
.config-table tbody tr:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.config-table-container {
overflow-x: auto;
max-width: 100%;
@@ -370,12 +461,13 @@ button:disabled {
.config-table th {
font-weight: bold;
height: 40px; /* Double the default height */
line-height: 40px; /* Center text vertically */
height: 24px; /* Base height for tbody rows */
line-height: 24px; /* Center text vertically */
}
.config-table tr:hover {
background-color: var(--muted-color);
.config-table td {
height: 16px; /* 50% taller than tbody rows would be */
line-height: 16px; /* Center text vertically */
}
/* Inline config value inputs - remove borders and padding to fit seamlessly in table cells */
@@ -713,19 +805,201 @@ button:disabled {
transition: all 0.2s ease;
}
/* Main Sections Wrapper */
.main-sections-wrapper {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
display: flex;
flex-wrap: wrap;
gap: var(--border-width);
/* SQL Query Interface Styles */
.query-selector {
margin-bottom: 15px;
}
.flex-section {
.query-selector select {
width: 100%;
padding: 8px;
background: var(--secondary-color);
color: var(--primary-color);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
font-family: var(--font-family);
font-size: 14px;
cursor: pointer;
}
.query-selector select:focus {
border-color: var(--accent-color);
outline: none;
}
.query-selector optgroup {
font-weight: bold;
color: var(--primary-color);
}
.query-selector option {
padding: 4px;
background: var(--secondary-color);
color: var(--primary-color);
}
.query-editor textarea {
width: 100%;
min-height: 120px;
resize: vertical;
font-family: "Courier New", Courier, monospace;
font-size: 12px;
line-height: 1.4;
tab-size: 4;
white-space: pre;
}
.query-actions {
display: flex;
gap: 10px;
margin-top: 10px;
}
.query-actions button {
flex: 1;
min-width: 300px;
min-width: 120px;
}
.primary-button {
background: var(--primary-color);
color: var(--secondary-color);
border-color: var(--primary-color);
}
.primary-button:hover {
background: var(--secondary-color);
color: var(--primary-color);
border-color: var(--accent-color);
}
.danger-button {
background: var(--accent-color);
color: var(--secondary-color);
border-color: var(--accent-color);
}
.danger-button:hover {
background: var(--secondary-color);
color: var(--primary-color);
border-color: var(--accent-color);
}
.query-info {
padding: 10px;
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
margin: 10px 0;
font-family: var(--font-family);
font-size: 12px;
background-color: var(--secondary-color);
}
.query-info-success {
border-color: #4CAF50;
background-color: #E8F5E8;
color: #2E7D32;
}
.query-info-success span {
display: inline-block;
margin-right: 15px;
}
.request-id {
font-family: "Courier New", Courier, monospace;
font-size: 10px;
opacity: 0.7;
}
.error-message {
border-color: var(--accent-color);
background-color: #FFEBEE;
color: #C62828;
padding: 10px;
border-radius: var(--border-radius);
margin: 10px 0;
font-family: var(--font-family);
font-size: 12px;
}
.sql-results-table {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin: 10px 0;
overflow: hidden;
font-size: 11px;
}
.sql-results-table th,
.sql-results-table td {
border: 0.1px solid var(--muted-color);
padding: 6px 8px;
text-align: left;
font-family: var(--font-family);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sql-results-table th {
font-weight: bold;
background-color: rgba(0, 0, 0, 0.05);
position: sticky;
top: 0;
z-index: 10;
}
.sql-results-table tbody tr:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.sql-results-table tbody tr:nth-child(even) {
background-color: rgba(0, 0, 0, 0.02);
}
.no-results {
text-align: center;
font-style: italic;
color: var(--muted-color);
padding: 20px;
font-family: var(--font-family);
}
.loading {
text-align: center;
font-style: italic;
color: var(--muted-color);
padding: 20px;
font-family: var(--font-family);
}
/* Dark mode adjustments for SQL interface */
body.dark-mode .query-info-success {
border-color: #4CAF50;
background-color: rgba(76, 175, 80, 0.1);
color: #81C784;
}
body.dark-mode .error-message {
border-color: var(--accent-color);
background-color: rgba(244, 67, 54, 0.1);
color: #EF5350;
}
body.dark-mode .sql-results-table th {
background-color: rgba(255, 255, 255, 0.05);
}
body.dark-mode .sql-results-table tbody tr:hover {
background-color: rgba(255, 255, 255, 0.05);
}
body.dark-mode .sql-results-table tbody tr:nth-child(even) {
background-color: rgba(255, 255, 255, 0.02);
}
@media (max-width: 700px) {
@@ -737,6 +1011,10 @@ button:disabled {
flex-direction: column;
}
.query-actions {
flex-direction: column;
}
h1 {
font-size: 20px;
}
@@ -744,4 +1022,14 @@ button:disabled {
h2 {
font-size: 14px;
}
.sql-results-table {
font-size: 10px;
}
.sql-results-table th,
.sql-results-table td {
padding: 4px 6px;
max-width: 120px;
}
}

View File

@@ -10,21 +10,38 @@
<body>
<!-- Header with title and profile display -->
<header class="main-header">
<div class="header-content">
<div class="header-title">RELAY</div>
<div class="profile-area" id="profile-area" style="display: none;">
<div class="profile-container">
<img id="header-user-image" class="header-user-image" alt="Profile" style="display: none;">
<span id="header-user-name" class="header-user-name">Loading...</span>
<div class="section">
<div class="header-content">
<div class="header-title">
<span class="relay-letter" data-letter="R">R</span>
<span class="relay-letter" data-letter="E">E</span>
<span class="relay-letter" data-letter="L">L</span>
<span class="relay-letter" data-letter="A">A</span>
<span class="relay-letter" data-letter="Y">Y</span>
</div>
<!-- Logout dropdown -->
<div class="logout-dropdown" id="logout-dropdown" style="display: none;">
<button type="button" id="logout-btn" class="logout-btn">LOGOUT</button>
<div class="relay-info">
<div id="relay-name" class="relay-name">C-Relay</div>
<div id="relay-description" class="relay-description">Loading...</div>
<div id="relay-pubkey-container" class="relay-pubkey-container">
<div id="relay-pubkey" class="relay-pubkey">Loading...</div>
</div>
</div>
<div class="profile-area" id="profile-area" style="display: none;">
<div class="admin-label">admin</div>
<div class="profile-container">
<img id="header-user-image" class="header-user-image" alt="Profile" style="display: none;">
<span id="header-user-name" class="header-user-name">Loading...</span>
</div>
<!-- Logout dropdown -->
<div class="logout-dropdown" id="logout-dropdown" style="display: none;">
<button type="button" id="dark-mode-btn" class="logout-btn">🌙 DARK MODE</button>
<button type="button" id="logout-btn" class="logout-btn">LOGOUT</button>
</div>
</div>
</div>
</div>
</header>
</div>
<!-- Login Modal Overlay -->
<div id="login-modal" class="login-modal-overlay" style="display: none;">
@@ -33,58 +50,6 @@
</div>
</div>
<!-- Main Sections Wrapper -->
<div class="main-sections-wrapper">
<!-- Relay Connection Section -->
<div id="relay-connection-section" class="flex-section">
<div class="section">
<h2>RELAY CONNECTION</h2>
<div class="input-group">
<label for="relay-connection-url">Relay URL:</label>
<input type="text" id="relay-connection-url" value=""
placeholder="ws://localhost:8888 or wss://relay.example.com">
</div>
<div class="input-group">
<label for="relay-pubkey-manual">Relay Pubkey (if not available via NIP-11):</label>
<input type="text" id="relay-pubkey-manual" placeholder="64-character hex pubkey"
pattern="[0-9a-fA-F]{64}" title="64-character hexadecimal public key">
</div>
<div class="inline-buttons">
<button type="button" id="connect-relay-btn">CONNECT TO RELAY</button>
<button type="button" id="disconnect-relay-btn" disabled>DISCONNECT</button>
<button type="button" id="restart-relay-btn" disabled>RESTART RELAY</button>
</div>
<div class="status disconnected" id="relay-connection-status">NOT CONNECTED</div>
<!-- Relay Information Display -->
<div id="relay-info-display" class="hidden">
<h3>Relay Information (NIP-11)</h3>
<table class="config-table" id="relay-info-table">
<thead>
<tr>
<th>Property</th>
<th>Value</th>
</tr>
</thead>
<tbody id="relay-info-table-body">
</tbody>
</table>
</div>
</div>
</div>
</div> <!-- End Main Sections Wrapper -->
<!-- DATABASE STATISTICS Section -->
<div class="section flex-section" id="databaseStatisticsSection" style="display: none;">
<div class="section-header">
@@ -95,36 +60,30 @@
<!-- Database Overview Table -->
<div class="input-group">
<label>Database Overview:</label>
<div class="config-table-container">
<table class="config-table" id="stats-overview-table">
<thead>
<tr>
<th>Metric</th>
<th>Value</th>
<th>Description</th>
</tr>
</thead>
<tbody id="stats-overview-table-body">
<tr>
<td>Database Size</td>
<td id="db-size">-</td>
<td>Current database file size</td>
</tr>
<tr>
<td>Total Events</td>
<td id="total-events">-</td>
<td>Total number of events stored</td>
</tr>
<tr>
<td>Oldest Event</td>
<td id="oldest-event">-</td>
<td>Timestamp of oldest event</td>
</tr>
<tr>
<td>Newest Event</td>
<td id="newest-event">-</td>
<td>Timestamp of newest event</td>
</tr>
</tbody>
</table>
@@ -161,24 +120,20 @@
<tr>
<th>Period</th>
<th>Events</th>
<th>Description</th>
</tr>
</thead>
<tbody id="stats-time-table-body">
<tr>
<td>Last 24 Hours</td>
<td id="events-24h">-</td>
<td>Events in the last day</td>
</tr>
<tr>
<td>Last 7 Days</td>
<td id="events-7d">-</td>
<td>Events in the last week</td>
</tr>
<tr>
<td>Last 30 Days</td>
<td id="events-30d">-</td>
<td>Events in the last month</td>
</tr>
</tbody>
</table>
@@ -319,6 +274,52 @@
</div>
</div>
<!-- SQL QUERY Section -->
<div class="section" id="sqlQuerySection" style="display: none;">
<div class="section-header">
<h2>SQL QUERY CONSOLE</h2>
</div>
<!-- Query Selector -->
<div class="input-group">
<label for="query-dropdown">Quick Queries & History:</label>
<select id="query-dropdown" onchange="loadSelectedQuery()">
<option value="">-- Select a query --</option>
<optgroup label="Common Queries">
<option value="recent_events">Recent Events</option>
<option value="event_stats">Event Statistics</option>
<option value="subscriptions">Active Subscriptions</option>
<option value="top_pubkeys">Top Pubkeys</option>
<option value="event_kinds">Event Kinds Distribution</option>
<option value="time_stats">Time-based Statistics</option>
</optgroup>
<optgroup label="Query History" id="history-group">
<!-- Dynamically populated from localStorage -->
</optgroup>
</select>
</div>
<!-- Query Editor -->
<div class="input-group">
<label for="sql-input">SQL Query:</label>
<textarea id="sql-input" rows="5" placeholder="SELECT * FROM events LIMIT 10"></textarea>
</div>
<!-- Query Actions -->
<div class="input-group">
<button type="button" id="execute-sql-btn" class="primary-button">EXECUTE QUERY</button>
<button type="button" id="clear-sql-btn">CLEAR</button>
<button type="button" id="clear-history-btn" class="danger-button">CLEAR HISTORY</button>
</div>
<!-- Query Results -->
<div class="input-group">
<label>Query Results:</label>
<div id="query-info" class="info-box"></div>
<div id="query-table" class="config-table-container"></div>
</div>
</div>
<!-- Load the official nostr-tools bundle first -->
<!-- <script src="https://laantungir.net/nostr-login-lite/nostr.bundle.js"></script> -->
<script src="/api/nostr.bundle.js"></script>

File diff suppressed because it is too large Load Diff

View File

@@ -9,11 +9,21 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILD_DIR="$SCRIPT_DIR/build"
DOCKERFILE="$SCRIPT_DIR/Dockerfile.alpine-musl"
echo "=========================================="
echo "C-Relay MUSL Static Binary Builder"
echo "=========================================="
# Parse command line arguments
DEBUG_BUILD=false
if [[ "$1" == "--debug" ]]; then
DEBUG_BUILD=true
echo "=========================================="
echo "C-Relay MUSL Static Binary Builder (DEBUG MODE)"
echo "=========================================="
else
echo "=========================================="
echo "C-Relay MUSL Static Binary Builder (PRODUCTION MODE)"
echo "=========================================="
fi
echo "Project directory: $SCRIPT_DIR"
echo "Build directory: $BUILD_DIR"
echo "Debug build: $DEBUG_BUILD"
echo ""
# Create build directory
@@ -83,6 +93,7 @@ echo ""
$DOCKER_CMD build \
--platform "$PLATFORM" \
--build-arg DEBUG_BUILD=$DEBUG_BUILD \
-f "$DOCKERFILE" \
-t c-relay-musl-builder:latest \
--progress=plain \
@@ -105,6 +116,7 @@ echo "=========================================="
# Build the builder stage to extract the binary
$DOCKER_CMD build \
--platform "$PLATFORM" \
--build-arg DEBUG_BUILD=$DEBUG_BUILD \
--target builder \
-f "$DOCKERFILE" \
-t c-relay-static-builder-stage:latest \
@@ -179,11 +191,16 @@ echo "=========================================="
echo "Binary: $BUILD_DIR/$OUTPUT_NAME"
echo "Size: $(du -h "$BUILD_DIR/$OUTPUT_NAME" | cut -f1)"
echo "Platform: $PLATFORM"
if [ "$DEBUG_BUILD" = true ]; then
echo "Build Type: DEBUG (with symbols, no optimization)"
else
echo "Build Type: PRODUCTION (optimized, stripped)"
fi
if [ "$TRULY_STATIC" = true ]; then
echo "Type: Fully static binary (Alpine MUSL-based)"
echo "Linkage: Fully static binary (Alpine MUSL-based)"
echo "Portability: Works on ANY Linux distribution"
else
echo "Type: Static binary (may have minimal dependencies)"
echo "Linkage: Static binary (may have minimal dependencies)"
fi
echo ""
echo "✓ Build complete!"

630
docs/sql_query_admin_api.md Normal file
View File

@@ -0,0 +1,630 @@
# SQL Query Admin API Design
## Overview
This document describes the design for a general-purpose SQL query interface for the C-Relay admin API. This allows administrators to execute read-only SQL queries against the relay database through cryptographically signed kind 23456 events with NIP-44 encrypted command arrays.
## Security Model
### Authentication
- All queries must be sent as kind 23456 events with NIP-44 encrypted content
- Events must be signed by the admin's private key
- Admin pubkey verified against `config.admin_pubkey`
- Follows the same authentication pattern as existing admin commands
### Query Restrictions
While authentication is cryptographically secure, we implement defensive safeguards:
1. **Read-Only Enforcement**
- Only SELECT statements allowed
- Block: INSERT, UPDATE, DELETE, DROP, CREATE, ALTER, PRAGMA (write operations)
- Allow: SELECT, WITH (for CTEs)
2. **Resource Limits**
- Query timeout: 5 seconds (configurable)
- Result row limit: 1000 rows (configurable)
- Result size limit: 1MB (configurable)
3. **Query Logging**
- All queries logged with timestamp, admin pubkey, execution time
- Failed queries logged with error message
## Command Format
### Admin Event Structure (Kind 23456)
```json
{
"id": "event_id",
"pubkey": "admin_public_key",
"created_at": 1234567890,
"kind": 23456,
"content": "AqHBUgcM7dXFYLQuDVzGwMST1G8jtWYyVvYxXhVGEu4nAb4LVw...",
"tags": [
["p", "relay_public_key"]
],
"sig": "event_signature"
}
```
The `content` field contains a NIP-44 encrypted JSON array:
```json
["sql_query", "SELECT * FROM events LIMIT 10"]
```
### Response Format (Kind 23457)
```json
["EVENT", "temp_sub_id", {
"id": "response_event_id",
"pubkey": "relay_public_key",
"created_at": 1234567890,
"kind": 23457,
"content": "nip44_encrypted_content",
"tags": [
["p", "admin_public_key"],
["e", "request_event_id"]
],
"sig": "response_event_signature"
}]
```
The `content` field contains NIP-44 encrypted JSON:
```json
{
"query_type": "sql_query",
"request_id": "request_event_id",
"timestamp": 1234567890,
"query": "SELECT * FROM events LIMIT 10",
"execution_time_ms": 45,
"row_count": 10,
"columns": ["id", "pubkey", "created_at", "kind", "content"],
"rows": [
["abc123...", "def456...", 1234567890, 1, "Hello world"],
...
]
}
```
**Note:** The response includes the request event ID in two places:
1. **In tags**: `["e", "request_event_id"]` - Standard Nostr convention for event references
2. **In content**: `"request_id": "request_event_id"` - For easy access after decryption
### Error Response Format (Kind 23457)
```json
["EVENT", "temp_sub_id", {
"id": "response_event_id",
"pubkey": "relay_public_key",
"created_at": 1234567890,
"kind": 23457,
"content": "nip44_encrypted_content",
"tags": [
["p", "admin_public_key"],
["e", "request_event_id"]
],
"sig": "response_event_signature"
}]
```
The `content` field contains NIP-44 encrypted JSON:
```json
{
"query_type": "sql_query",
"request_id": "request_event_id",
"timestamp": 1234567890,
"query": "DELETE FROM events",
"status": "error",
"error": "Query blocked: DELETE statements not allowed",
"error_type": "blocked_statement"
}
```
## Available Database Tables and Views
### Core Tables
- **events** - All Nostr events (id, pubkey, created_at, kind, content, tags, sig)
- **config** - Configuration key-value pairs
- **auth_rules** - Authentication and authorization rules
- **subscription_events** - Subscription lifecycle events
- **event_broadcasts** - Event broadcast log
### Useful Views
- **recent_events** - Last 1000 events
- **event_stats** - Event statistics by type
- **configuration_events** - Kind 33334 configuration events
- **subscription_analytics** - Subscription metrics by date
- **active_subscriptions_log** - Currently active subscriptions
- **event_kinds_view** - Event distribution by kind
- **top_pubkeys_view** - Top 10 pubkeys by event count
- **time_stats_view** - Time-based statistics (24h, 7d, 30d)
## Implementation Plan
### Backend (dm_admin.c)
#### 1. Query Validation Function
```c
int validate_sql_query(const char* query, char* error_msg, size_t error_size);
```
- Check for blocked keywords (case-insensitive)
- Validate query syntax (basic checks)
- Return 0 on success, -1 on failure
#### 2. Query Execution Function
```c
char* execute_sql_query(const char* query, char* error_msg, size_t error_size);
```
- Set query timeout using sqlite3_busy_timeout()
- Execute query with row/size limits
- Build JSON response with results
- Log query execution
- Return JSON string or NULL on error
#### 3. Command Handler Integration
Add to `process_dm_admin_command()` in [`dm_admin.c`](src/dm_admin.c:131):
```c
else if (strcmp(command_type, "sql_query") == 0) {
const char* query = get_tag_value(event, "sql_query", 1);
if (!query) {
DEBUG_ERROR("DM Admin: Missing sql_query parameter");
snprintf(error_message, error_size, "invalid: missing SQL query");
} else {
result = handle_sql_query_unified(event, query, error_message, error_size, wsi);
}
}
```
Add unified handler function:
```c
int handle_sql_query_unified(cJSON* event, const char* query,
char* error_message, size_t error_size,
struct lws* wsi) {
// Get request event ID for response correlation
cJSON* request_id_obj = cJSON_GetObjectItem(event, "id");
if (!request_id_obj || !cJSON_IsString(request_id_obj)) {
snprintf(error_message, error_size, "Missing request event ID");
return -1;
}
const char* request_id = cJSON_GetStringValue(request_id_obj);
// Validate query
if (!validate_sql_query(query, error_message, error_size)) {
return -1;
}
// Execute query and include request_id in result
char* result_json = execute_sql_query(query, request_id, error_message, error_size);
if (!result_json) {
return -1;
}
// Send response as kind 23457 event with request ID in tags
cJSON* sender_pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
if (!sender_pubkey_obj || !cJSON_IsString(sender_pubkey_obj)) {
free(result_json);
snprintf(error_message, error_size, "Missing sender pubkey");
return -1;
}
const char* sender_pubkey = cJSON_GetStringValue(sender_pubkey_obj);
int send_result = send_admin_response(sender_pubkey, result_json, request_id,
error_message, error_size, wsi);
free(result_json);
return send_result;
}
```
### Frontend (api/index.html)
#### SQL Query Section UI
Add to [`api/index.html`](api/index.html:1):
```html
<section id="sql-query-section" class="admin-section">
<h2>SQL Query Console</h2>
<div class="query-selector">
<label for="query-dropdown">Quick Queries & History:</label>
<select id="query-dropdown" onchange="loadSelectedQuery()">
<option value="">-- Select a query --</option>
<optgroup label="Common Queries">
<option value="recent_events">Recent Events</option>
<option value="event_stats">Event Statistics</option>
<option value="subscriptions">Active Subscriptions</option>
<option value="top_pubkeys">Top Pubkeys</option>
<option value="event_kinds">Event Kinds Distribution</option>
<option value="time_stats">Time-based Statistics</option>
</optgroup>
<optgroup label="Query History" id="history-group">
<!-- Dynamically populated from localStorage -->
</optgroup>
</select>
</div>
<div class="query-editor">
<label for="sql-input">SQL Query:</label>
<textarea id="sql-input" rows="5" placeholder="SELECT * FROM events LIMIT 10"></textarea>
<div class="query-actions">
<button onclick="executeSqlQuery()" class="primary-button">Execute Query</button>
<button onclick="clearSqlQuery()">Clear</button>
<button onclick="clearQueryHistory()" class="danger-button">Clear History</button>
</div>
</div>
<div class="query-results">
<h3>Results</h3>
<div id="query-info" class="info-box"></div>
<div id="query-table" class="table-container"></div>
</div>
</section>
```
#### JavaScript Functions (api/index.js)
Add to [`api/index.js`](api/index.js:1):
```javascript
// Predefined query templates
const SQL_QUERY_TEMPLATES = {
recent_events: "SELECT id, pubkey, created_at, kind, substr(content, 1, 50) as content FROM events ORDER BY created_at DESC LIMIT 20",
event_stats: "SELECT * FROM event_stats",
subscriptions: "SELECT * FROM active_subscriptions_log ORDER BY created_at DESC",
top_pubkeys: "SELECT * FROM top_pubkeys_view",
event_kinds: "SELECT * FROM event_kinds_view ORDER BY count DESC",
time_stats: "SELECT * FROM time_stats_view"
};
// Query history management (localStorage)
const QUERY_HISTORY_KEY = 'c_relay_sql_history';
const MAX_HISTORY_ITEMS = 20;
// Load query history from localStorage
function loadQueryHistory() {
try {
const history = localStorage.getItem(QUERY_HISTORY_KEY);
return history ? JSON.parse(history) : [];
} catch (e) {
console.error('Failed to load query history:', e);
return [];
}
}
// Save query to history
function saveQueryToHistory(query) {
if (!query || query.trim().length === 0) return;
try {
let history = loadQueryHistory();
// Remove duplicate if exists
history = history.filter(q => q !== query);
// Add to beginning
history.unshift(query);
// Limit size
if (history.length > MAX_HISTORY_ITEMS) {
history = history.slice(0, MAX_HISTORY_ITEMS);
}
localStorage.setItem(QUERY_HISTORY_KEY, JSON.stringify(history));
updateQueryDropdown();
} catch (e) {
console.error('Failed to save query history:', e);
}
}
// Clear query history
function clearQueryHistory() {
if (confirm('Clear all query history?')) {
localStorage.removeItem(QUERY_HISTORY_KEY);
updateQueryDropdown();
}
}
// Update dropdown with history
function updateQueryDropdown() {
const historyGroup = document.getElementById('history-group');
if (!historyGroup) return;
// Clear existing history options
historyGroup.innerHTML = '';
const history = loadQueryHistory();
if (history.length === 0) {
const option = document.createElement('option');
option.value = '';
option.textContent = '(no history)';
option.disabled = true;
historyGroup.appendChild(option);
return;
}
history.forEach((query, index) => {
const option = document.createElement('option');
option.value = `history_${index}`;
// Truncate long queries for display
const displayQuery = query.length > 60 ? query.substring(0, 60) + '...' : query;
option.textContent = displayQuery;
option.dataset.query = query;
historyGroup.appendChild(option);
});
}
// Load selected query from dropdown
function loadSelectedQuery() {
const dropdown = document.getElementById('query-dropdown');
const selectedValue = dropdown.value;
if (!selectedValue) return;
let query = '';
// Check if it's a template
if (SQL_QUERY_TEMPLATES[selectedValue]) {
query = SQL_QUERY_TEMPLATES[selectedValue];
}
// Check if it's from history
else if (selectedValue.startsWith('history_')) {
const selectedOption = dropdown.options[dropdown.selectedIndex];
query = selectedOption.dataset.query;
}
if (query) {
document.getElementById('sql-input').value = query;
}
// Reset dropdown to placeholder
dropdown.value = '';
}
// Initialize query history on page load
document.addEventListener('DOMContentLoaded', function() {
updateQueryDropdown();
});
// Clear the SQL query input
function clearSqlQuery() {
document.getElementById('sql-input').value = '';
document.getElementById('query-info').innerHTML = '';
document.getElementById('query-table').innerHTML = '';
}
// Track pending SQL queries by request ID
const pendingSqlQueries = new Map();
// Execute SQL query via admin API
async function executeSqlQuery() {
const query = document.getElementById('sql-input').value;
if (!query.trim()) {
showError('Please enter a SQL query');
return;
}
try {
// Show loading state
document.getElementById('query-info').innerHTML = '<div class="loading">Executing query...</div>';
document.getElementById('query-table').innerHTML = '';
// Save to history (before execution, so it's saved even if query fails)
saveQueryToHistory(query.trim());
// Send query as kind 23456 admin command
const command = ["sql_query", query];
const requestEvent = await sendAdminCommand(command);
// Store query info for when response arrives
if (requestEvent && requestEvent.id) {
pendingSqlQueries.set(requestEvent.id, {
query: query,
timestamp: Date.now()
});
}
// Note: Response will be handled by the event listener
// which will call displaySqlQueryResults() when response arrives
} catch (error) {
showError('Failed to execute query: ' + error.message);
}
}
// Handle SQL query response (called by event listener)
function handleSqlQueryResponse(response) {
// Check if this is a response to one of our queries
if (response.request_id && pendingSqlQueries.has(response.request_id)) {
const queryInfo = pendingSqlQueries.get(response.request_id);
pendingSqlQueries.delete(response.request_id);
// Display results
displaySqlQueryResults(response);
}
}
// Display SQL query results
function displaySqlQueryResults(response) {
const infoDiv = document.getElementById('query-info');
const tableDiv = document.getElementById('query-table');
if (response.status === 'error' || response.error) {
infoDiv.innerHTML = `<div class="error-message">❌ ${response.error || 'Query failed'}</div>`;
tableDiv.innerHTML = '';
return;
}
// Show query info with request ID for debugging
const rowCount = response.row_count || 0;
const execTime = response.execution_time_ms || 0;
const requestId = response.request_id ? response.request_id.substring(0, 8) + '...' : 'unknown';
infoDiv.innerHTML = `
<div class="query-info-success">
<span>✅ Query executed successfully</span>
<span>Rows: ${rowCount}</span>
<span>Execution Time: ${execTime}ms</span>
<span class="request-id" title="${response.request_id || ''}">Request: ${requestId}</span>
</div>
`;
// Build results table
if (response.rows && response.rows.length > 0) {
let html = '<table class="sql-results-table"><thead><tr>';
response.columns.forEach(col => {
html += `<th>${escapeHtml(col)}</th>`;
});
html += '</tr></thead><tbody>';
response.rows.forEach(row => {
html += '<tr>';
row.forEach(cell => {
const cellValue = cell === null ? '<em>NULL</em>' : escapeHtml(String(cell));
html += `<td>${cellValue}</td>`;
});
html += '</tr>';
});
html += '</tbody></table>';
tableDiv.innerHTML = html;
} else {
tableDiv.innerHTML = '<p class="no-results">No results returned</p>';
}
}
// Helper function to escape HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
```
## Example Queries
### Subscription Statistics
```sql
SELECT
date,
subscriptions_created,
subscriptions_ended,
avg_duration_seconds,
unique_clients
FROM subscription_analytics
ORDER BY date DESC
LIMIT 7;
```
### Event Distribution by Kind
```sql
SELECT kind, count, percentage
FROM event_kinds_view
ORDER BY count DESC;
```
### Recent Events by Specific Pubkey
```sql
SELECT id, created_at, kind, content
FROM events
WHERE pubkey = 'abc123...'
ORDER BY created_at DESC
LIMIT 20;
```
### Active Subscriptions with Details
```sql
SELECT
subscription_id,
client_ip,
events_sent,
duration_seconds,
filter_json
FROM active_subscriptions_log
ORDER BY created_at DESC;
```
### Database Size and Event Count
```sql
SELECT
(SELECT COUNT(*) FROM events) as total_events,
(SELECT COUNT(*) FROM subscription_events) as total_subscriptions,
(SELECT COUNT(*) FROM auth_rules WHERE active = 1) as active_rules;
```
## Configuration Options
Add to config table:
```sql
INSERT INTO config (key, value, data_type, description, category) VALUES
('sql_query_enabled', 'true', 'boolean', 'Enable SQL query admin API', 'admin'),
('sql_query_timeout', '5', 'integer', 'Query timeout in seconds', 'admin'),
('sql_query_row_limit', '1000', 'integer', 'Maximum rows per query', 'admin'),
('sql_query_size_limit', '1048576', 'integer', 'Maximum result size in bytes', 'admin'),
('sql_query_log_enabled', 'true', 'boolean', 'Log all SQL queries', 'admin');
```
## Security Considerations
### What This Protects Against
1. **Unauthorized Access** - Only admin can execute queries (cryptographic verification)
2. **Data Modification** - Read-only enforcement prevents accidental/malicious changes
3. **Resource Exhaustion** - Timeouts and limits prevent DoS
4. **Audit Trail** - All queries logged for security review
### What This Does NOT Protect Against
1. **Admin Compromise** - If admin private key is stolen, attacker has full read access
2. **Information Disclosure** - Admin can read all data (by design)
3. **Complex Attacks** - Sophisticated SQL injection might bypass simple keyword blocking
### Recommendations
1. **Secure Admin Key** - Store admin private key securely, never commit to git
2. **Monitor Query Logs** - Review query logs regularly for suspicious activity
3. **Backup Database** - Regular backups in case of issues
4. **Test Queries** - Test complex queries on development relay first
## Testing Plan
### Unit Tests
1. Query validation (blocked keywords, syntax)
2. Result formatting (JSON structure)
3. Error handling (timeouts, limits)
### Integration Tests
1. Execute queries through NIP-17 DM
2. Verify authentication (admin vs non-admin)
3. Test resource limits (timeout, row limit)
4. Test error responses
### Security Tests
1. Attempt blocked statements (INSERT, DELETE, etc.)
2. Attempt SQL injection patterns
3. Test query timeout with slow queries
4. Test row limit with large result sets
## Future Enhancements
1. **Query History** - Store recent queries for quick re-execution
2. **Query Favorites** - Save frequently used queries
3. **Export Results** - Download results as CSV/JSON
4. **Query Builder** - Visual query builder for common operations
5. **Real-time Updates** - WebSocket updates for live data
6. **Query Sharing** - Share queries with other admins (if multi-admin support added)
## Migration Path
### Phase 1: Backend Implementation
1. Add query validation function
2. Add query execution function
3. Integrate with NIP-17 command handler
4. Add configuration options
5. Add query logging
### Phase 2: Frontend Implementation
1. Add SQL query section to index.html
2. Add query execution JavaScript
3. Add predefined query templates
4. Add results display formatting
### Phase 3: Testing and Documentation
1. Write unit tests
2. Write integration tests
3. Update user documentation
4. Create query examples guide
### Phase 4: Enhancement
1. Add query history
2. Add export functionality
3. Optimize performance
4. Add more predefined templates

258
docs/sql_test_design.md Normal file
View File

@@ -0,0 +1,258 @@
# SQL Query Test Script Design
## Overview
Test script for validating the SQL query admin API functionality. Tests query validation, execution, error handling, and security features.
## Script: tests/sql_test.sh
### Test Categories
#### 1. Query Validation Tests
- ✅ Valid SELECT queries accepted
- ❌ INSERT statements blocked
- ❌ UPDATE statements blocked
- ❌ DELETE statements blocked
- ❌ DROP statements blocked
- ❌ CREATE statements blocked
- ❌ ALTER statements blocked
- ❌ PRAGMA write operations blocked
#### 2. Query Execution Tests
- ✅ Simple SELECT query
- ✅ SELECT with WHERE clause
- ✅ SELECT with JOIN
- ✅ SELECT with ORDER BY and LIMIT
- ✅ Query against views
- ✅ Query with aggregate functions (COUNT, SUM, AVG)
#### 3. Response Format Tests
- ✅ Response includes request_id
- ✅ Response includes query_type
- ✅ Response includes columns array
- ✅ Response includes rows array
- ✅ Response includes row_count
- ✅ Response includes execution_time_ms
#### 4. Error Handling Tests
- ❌ Invalid SQL syntax
- ❌ Non-existent table
- ❌ Non-existent column
- ❌ Query timeout (if configurable)
#### 5. Security Tests
- ❌ SQL injection attempts blocked
- ❌ Nested query attacks blocked
- ❌ Comment-based attacks blocked
#### 6. Concurrent Query Tests
- ✅ Multiple queries in parallel
- ✅ Responses correctly correlated to requests
## Script Structure
```bash
#!/bin/bash
# SQL Query Admin API Test Script
# Tests the sql_query command functionality
set -e
RELAY_URL="${RELAY_URL:-ws://localhost:8888}"
ADMIN_PRIVKEY="${ADMIN_PRIVKEY:-}"
RELAY_PUBKEY="${RELAY_PUBKEY:-}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Test counters
TESTS_RUN=0
TESTS_PASSED=0
TESTS_FAILED=0
# Helper functions
print_test() {
echo -e "${YELLOW}TEST: $1${NC}"
TESTS_RUN=$((TESTS_RUN + 1))
}
print_pass() {
echo -e "${GREEN}✓ PASS: $1${NC}"
TESTS_PASSED=$((TESTS_PASSED + 1))
}
print_fail() {
echo -e "${RED}✗ FAIL: $1${NC}"
TESTS_FAILED=$((TESTS_FAILED + 1))
}
# Send SQL query command
send_sql_query() {
local query="$1"
# Implementation using nostr CLI tools or curl
# Returns response JSON
}
# Test functions
test_valid_select() {
print_test "Valid SELECT query"
local response=$(send_sql_query "SELECT * FROM events LIMIT 1")
if echo "$response" | grep -q '"query_type":"sql_query"'; then
print_pass "Valid SELECT accepted"
else
print_fail "Valid SELECT rejected"
fi
}
test_blocked_insert() {
print_test "INSERT statement blocked"
local response=$(send_sql_query "INSERT INTO events VALUES (...)")
if echo "$response" | grep -q '"error"'; then
print_pass "INSERT correctly blocked"
else
print_fail "INSERT not blocked"
fi
}
# ... more test functions ...
# Main test execution
main() {
echo "================================"
echo "SQL Query Admin API Tests"
echo "================================"
echo ""
# Check prerequisites
if [ -z "$ADMIN_PRIVKEY" ]; then
echo "Error: ADMIN_PRIVKEY not set"
exit 1
fi
# Run test suites
echo "1. Query Validation Tests"
test_valid_select
test_blocked_insert
test_blocked_update
test_blocked_delete
test_blocked_drop
echo ""
echo "2. Query Execution Tests"
test_simple_select
test_select_with_where
test_select_with_join
test_select_views
echo ""
echo "3. Response Format Tests"
test_response_format
test_request_id_correlation
echo ""
echo "4. Error Handling Tests"
test_invalid_syntax
test_nonexistent_table
echo ""
echo "5. Security Tests"
test_sql_injection
echo ""
echo "6. Concurrent Query Tests"
test_concurrent_queries
# Print summary
echo ""
echo "================================"
echo "Test Summary"
echo "================================"
echo "Tests Run: $TESTS_RUN"
echo "Tests Passed: $TESTS_PASSED"
echo "Tests Failed: $TESTS_FAILED"
if [ $TESTS_FAILED -eq 0 ]; then
echo -e "${GREEN}All tests passed!${NC}"
exit 0
else
echo -e "${RED}Some tests failed${NC}"
exit 1
fi
}
main "$@"
```
## Test Data Setup
The script should work with the existing relay database without requiring special test data, using:
- Existing events table
- Existing views (event_stats, recent_events, etc.)
- Existing config table
## Usage
```bash
# Set environment variables
export ADMIN_PRIVKEY="your_admin_private_key_hex"
export RELAY_PUBKEY="relay_public_key_hex"
export RELAY_URL="ws://localhost:8888"
# Run tests
./tests/sql_test.sh
# Run specific test category
./tests/sql_test.sh validation
./tests/sql_test.sh security
```
## Integration with CI/CD
The script should:
- Return exit code 0 on success, 1 on failure
- Output TAP (Test Anything Protocol) format for CI integration
- Be runnable in automated test pipelines
- Not require manual intervention
## Dependencies
- `bash` (version 4+)
- `curl` or `websocat` for WebSocket communication
- `jq` for JSON parsing
- Nostr CLI tools (optional, for event signing)
- Running c-relay instance
## Example Output
```
================================
SQL Query Admin API Tests
================================
1. Query Validation Tests
TEST: Valid SELECT query
✓ PASS: Valid SELECT accepted
TEST: INSERT statement blocked
✓ PASS: INSERT correctly blocked
TEST: UPDATE statement blocked
✓ PASS: UPDATE correctly blocked
2. Query Execution Tests
TEST: Simple SELECT query
✓ PASS: Query executed successfully
TEST: SELECT with WHERE clause
✓ PASS: WHERE clause works correctly
...
================================
Test Summary
================================
Tests Run: 24
Tests Passed: 24
Tests Failed: 0
All tests passed!

View File

@@ -39,6 +39,40 @@ Even simpler: Use this one-liner
cd /usr/local/bin/c_relay
sudo -u c-relay ./c_relay --debug-level=5 & sleep 2 && sudo gdb -p $(pgrep c_relay)
Once gdb attaches, type continue and wait for the crash. This way the relay starts normally and gdb just monitors it.
Which approach would you like to try?
How to View the Logs
Check systemd journal:
# View all c-relay logs
sudo journalctl -u c-relay
# View recent logs (last 50 lines)
sudo journalctl -u c-relay -n 50
# Follow logs in real-time
sudo journalctl -u c-relay -f
# View logs since last boot
sudo journalctl -u c-relay -b
Check if service is running:
To immediately trim the syslog file size:
Safe Syslog Truncation
Stop syslog service first:
sudo systemctl stop rsyslog
Truncate the syslog file:
sudo truncate -s 0 /var/log/syslog
Restart syslog service:
sudo systemctl start rsyslog
sudo systemctl status rsyslog
sudo -u c-relay ./c_relay --debug-level=5 -r 85d0b37e2ae822966dcadd06b2dc9368cde73865f90ea4d44f8b57d47ef0820a -a 1ec454734dcbf6fe54901ce25c0c7c6bca5edd89443416761fadc321d38df139
./c_relay_static_x86_64 -p 7889 --debug-level=5 -r 85d0b37e2ae822966dcadd06b2dc9368cde73865f90ea4d44f8b57d47ef0820a -a 1ec454734dcbf6fe54901ce25c0c7c6bca5edd89443416761fadc321d38df139

View File

@@ -1 +1 @@
3928044
752613

1526
src/api.c

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
// API module for serving embedded web content
// API module for serving embedded web content and admin API functions
#ifndef API_H
#define API_H
#include <libwebsockets.h>
#include <cjson/cJSON.h>
// Embedded file session data structure for managing buffer lifetime
struct embedded_file_session_data {
@@ -14,10 +15,48 @@ struct embedded_file_session_data {
int body_sent;
};
// Configuration change pending structure
typedef struct pending_config_change {
char admin_pubkey[65]; // Who requested the change
char config_key[128]; // What config to change
char old_value[256]; // Current value
char new_value[256]; // Requested new value
time_t timestamp; // When requested
char change_id[33]; // Unique ID for this change (first 32 chars of hash)
struct pending_config_change* next; // Linked list for concurrent changes
} pending_config_change_t;
// Handle HTTP request for embedded API files
int handle_embedded_file_request(struct lws* wsi, const char* requested_uri);
// Generate stats JSON from database queries
char* generate_stats_json(void);
// Generate human-readable stats text
char* generate_stats_text(void);
// Generate config text from database
char* generate_config_text(void);
// Send admin response with request ID correlation
int send_admin_response(const char* sender_pubkey, const char* response_content, const char* request_id,
char* error_message, size_t error_size, struct lws* wsi);
// Configuration change system functions
int parse_config_command(const char* message, char* key, char* value);
int validate_config_change(const char* key, const char* value);
char* store_pending_config_change(const char* admin_pubkey, const char* key,
const char* old_value, const char* new_value);
pending_config_change_t* find_pending_change(const char* admin_pubkey, const char* change_id);
int apply_config_change(const char* key, const char* value);
void cleanup_expired_pending_changes(void);
int handle_config_confirmation(const char* admin_pubkey, const char* response);
char* generate_config_change_confirmation(const char* key, const char* old_value, const char* new_value);
int process_config_change_request(const char* admin_pubkey, const char* message);
// SQL query functions
int validate_sql_query(const char* query, char* error_message, size_t error_size);
char* execute_sql_query(const char* query, const char* request_id, char* error_message, size_t error_size);
int handle_sql_query_unified(cJSON* event, const char* query, char* error_message, size_t error_size, struct lws* wsi);
#endif // API_H

File diff suppressed because it is too large Load Diff

View File

@@ -24,4 +24,11 @@ int send_nip17_response(const char* sender_pubkey, const char* response_content,
char* generate_config_text(void);
char* generate_stats_text(void);
// SQL query admin functions
int validate_sql_query(const char* query, char* error_message, size_t error_size);
char* execute_sql_query(const char* query, const char* request_id, char* error_message, size_t error_size);
int handle_sql_query_unified(cJSON* event, const char* query, char* error_message, size_t error_size, struct lws* wsi);
int send_admin_response(const char* sender_pubkey, const char* response_content, const char* request_id,
char* error_message, size_t error_size, struct lws* wsi);
#endif // DM_ADMIN_H

File diff suppressed because one or more lines are too long

View File

@@ -123,7 +123,7 @@ void free_subscription_filter(subscription_filter_t* filter) {
}
// Validate subscription ID format and length
static int validate_subscription_id(const char* sub_id) {
int validate_subscription_id(const char* sub_id) {
if (!sub_id) {
return 0; // NULL pointer
}
@@ -133,11 +133,11 @@ static int validate_subscription_id(const char* sub_id) {
return 0; // Empty or too long
}
// Check for valid characters (alphanumeric, underscore, hyphen, colon)
// Check for valid characters (alphanumeric, underscore, hyphen, colon, comma)
for (size_t i = 0; i < len; i++) {
char c = sub_id[i];
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '_' || c == '-' || c == ':')) {
(c >= '0' && c <= '9') || c == '_' || c == '-' || c == ':' || c == ',')) {
return 0; // Invalid character
}
}

View File

@@ -93,6 +93,7 @@ struct subscription_manager {
};
// Function declarations
int validate_subscription_id(const char* sub_id);
subscription_filter_t* create_subscription_filter(cJSON* filter_json);
void free_subscription_filter(subscription_filter_t* filter);
subscription_t* create_subscription(const char* sub_id, struct lws* wsi, cJSON* filters_array, const char* client_ip);

View File

@@ -707,38 +707,10 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
return 0;
}
// Check subscription ID format and length
size_t id_len = strlen(subscription_id);
if (id_len == 0 || id_len >= SUBSCRIPTION_ID_MAX_LENGTH) {
send_notice_message(wsi, "error: subscription ID too long or empty");
DEBUG_WARN("REQ rejected: invalid subscription ID length");
cJSON_Delete(json);
free(message);
return 0;
}
// Validate characters in subscription ID
int valid_id = 1;
char invalid_char = '\0';
size_t invalid_pos = 0;
for (size_t i = 0; i < id_len; i++) {
char c = subscription_id[i];
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '_' || c == '-' || c == ':')) {
valid_id = 0;
invalid_char = c;
invalid_pos = i;
break;
}
}
if (!valid_id) {
char debug_msg[512];
snprintf(debug_msg, sizeof(debug_msg),
"REQ rejected: invalid character '%c' (0x%02X) at position %zu in subscription ID: '%s'",
invalid_char, (unsigned char)invalid_char, invalid_pos, subscription_id);
DEBUG_WARN(debug_msg);
send_notice_message(wsi, "error: invalid characters in subscription ID");
// Validate subscription ID
if (!validate_subscription_id(subscription_id)) {
send_notice_message(wsi, "error: invalid subscription ID");
DEBUG_WARN("REQ rejected: invalid subscription ID");
cJSON_Delete(json);
free(message);
return 0;
@@ -866,30 +838,10 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
return 0;
}
// Check subscription ID format and length
size_t id_len = strlen(subscription_id);
if (id_len == 0 || id_len >= SUBSCRIPTION_ID_MAX_LENGTH) {
send_notice_message(wsi, "error: subscription ID too long or empty in CLOSE");
DEBUG_WARN("CLOSE rejected: invalid subscription ID length");
cJSON_Delete(json);
free(message);
return 0;
}
// Validate characters in subscription ID
int valid_id = 1;
for (size_t i = 0; i < id_len; i++) {
char c = subscription_id[i];
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '_' || c == '-' || c == ':')) {
valid_id = 0;
break;
}
}
if (!valid_id) {
send_notice_message(wsi, "error: invalid characters in subscription ID for CLOSE");
DEBUG_WARN("CLOSE rejected: invalid characters in subscription ID");
// Validate subscription ID
if (!validate_subscription_id(subscription_id)) {
send_notice_message(wsi, "error: invalid subscription ID in CLOSE");
DEBUG_WARN("CLOSE rejected: invalid subscription ID");
cJSON_Delete(json);
free(message);
return 0;

448
tests/sql_test.sh Executable file
View File

@@ -0,0 +1,448 @@
#!/bin/bash
# SQL Query Admin API Test Script
# Tests the sql_query command functionality
set -e
# Configuration
RELAY_URL="ws://localhost:8888"
ADMIN_PRIVKEY="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
ADMIN_PUBKEY="6a04ab98d9e4774ad806e302dddeb63bea16b5cb5f223ee77478e861bb583eb3"
RELAY_PUBKEY="4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Test counters
TOTAL_TESTS=0
PASSED_TESTS=0
FAILED_TESTS=0
# Helper functions
print_test() {
echo -e "${YELLOW}TEST: $1${NC}"
TOTAL_TESTS=$((TOTAL_TESTS + 1))
}
print_pass() {
echo -e "${GREEN}✓ PASS: $1${NC}"
PASSED_TESTS=$((PASSED_TESTS + 1))
}
print_fail() {
echo -e "${RED}✗ FAIL: $1${NC}"
FAILED_TESTS=$((FAILED_TESTS + 1))
}
# Check if nak is installed
check_nak() {
if ! command -v nak &> /dev/null; then
echo -e "${RED}ERROR: nak command not found. Please install nak first.${NC}"
echo -e "${RED}Visit: https://github.com/fiatjaf/nak${NC}"
exit 1
fi
echo -e "${GREEN}✓ nak is available${NC}"
}
# Send SQL query command via WebSocket using nak
send_sql_query() {
local query="$1"
local description="$2"
echo -n "Testing $description... "
# Create the admin command
COMMAND="[\"sql_query\", \"$query\"]"
# Encrypt the command using NIP-44
ENCRYPTED_COMMAND=$(nak encrypt "$COMMAND" \
--sec "$ADMIN_PRIVKEY" \
--recipient-pubkey "$RELAY_PUBKEY" 2>/dev/null)
if [ -z "$ENCRYPTED_COMMAND" ]; then
echo -e "${RED}FAILED${NC} - Failed to encrypt admin command"
return 1
fi
# Create admin event
ADMIN_EVENT=$(nak event \
--kind 23456 \
--content "$ENCRYPTED_COMMAND" \
--sec "$ADMIN_PRIVKEY" \
--tag "p=$RELAY_PUBKEY" 2>/dev/null)
if [ -z "$ADMIN_EVENT" ]; then
echo -e "${RED}FAILED${NC} - Failed to create admin event"
return 1
fi
echo "=== SENT EVENT ==="
echo "$ADMIN_EVENT"
echo "==================="
# Send SQL query event via WebSocket
local response
response=$(echo "$ADMIN_EVENT" | timeout 10 websocat -B 1048576 "$RELAY_URL" 2>/dev/null | head -3 || echo 'TIMEOUT')
echo "=== RECEIVED RESPONSE ==="
echo "$response"
echo "=========================="
if [[ "$response" == *"TIMEOUT"* ]]; then
echo -e "${RED}FAILED${NC} - Connection timeout"
return 1
fi
echo "$response" # Return the response for further processing
}
# Test functions
test_valid_select() {
print_test "Valid SELECT query"
local response=$(send_sql_query "SELECT * FROM events LIMIT 1" "valid SELECT query")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"query_type":"sql_query"' && echo "$response" | grep -q '"row_count"'; then
print_pass "Valid SELECT accepted and executed"
else
print_fail "Valid SELECT failed: $response"
fi
}
test_select_count() {
print_test "SELECT COUNT(*) query"
local response=$(send_sql_query "SELECT COUNT(*) FROM events" "COUNT query")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"query_type":"sql_query"' && echo "$response" | grep -q '"row_count"'; then
print_pass "COUNT query executed successfully"
else
print_fail "COUNT query failed: $response"
fi
}
test_blocked_insert() {
print_test "INSERT statement blocked"
local response=$(send_sql_query "INSERT INTO events VALUES ('id', 'pubkey', 1234567890, 1, 'content', 'sig')" "INSERT blocking")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"status":"error"' && echo "$response" | grep -q '"error_type":"blocked_statement"'; then
print_pass "INSERT correctly blocked"
else
print_fail "INSERT not blocked: $response"
fi
}
test_blocked_update() {
print_test "UPDATE statement blocked"
local response=$(send_sql_query "UPDATE events SET content = 'test' WHERE id = 'abc123'" "UPDATE blocking")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"status":"error"' && echo "$response" | grep -q '"error_type":"blocked_statement"'; then
print_pass "UPDATE correctly blocked"
else
print_fail "UPDATE not blocked: $response"
fi
}
test_blocked_delete() {
print_test "DELETE statement blocked"
local response=$(send_sql_query "DELETE FROM events WHERE id = 'abc123'" "DELETE blocking")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"status":"error"' && echo "$response" | grep -q '"error_type":"blocked_statement"'; then
print_pass "DELETE correctly blocked"
else
print_fail "DELETE not blocked: $response"
fi
}
test_blocked_drop() {
print_test "DROP statement blocked"
local response=$(send_sql_query "DROP TABLE events" "DROP blocking")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"status":"error"' && echo "$response" | grep -q '"error_type":"blocked_statement"'; then
print_pass "DROP correctly blocked"
else
print_fail "DROP not blocked: $response"
fi
}
test_blocked_create() {
print_test "CREATE statement blocked"
local response=$(send_sql_query "CREATE TABLE test (id TEXT)" "CREATE blocking")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"status":"error"' && echo "$response" | grep -q '"error_type":"blocked_statement"'; then
print_pass "CREATE correctly blocked"
else
print_fail "CREATE not blocked: $response"
fi
}
test_blocked_alter() {
print_test "ALTER statement blocked"
local response=$(send_sql_query "ALTER TABLE events ADD COLUMN test TEXT" "ALTER blocking")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"status":"error"' && echo "$response" | grep -q '"error_type":"blocked_statement"'; then
print_pass "ALTER correctly blocked"
else
print_fail "ALTER not blocked: $response"
fi
}
test_blocked_pragma() {
print_test "PRAGMA statement blocked"
local response=$(send_sql_query "PRAGMA table_info(events)" "PRAGMA blocking")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"status":"error"' && echo "$response" | grep -q '"error_type":"blocked_statement"'; then
print_pass "PRAGMA correctly blocked"
else
print_fail "PRAGMA not blocked: $response"
fi
}
test_select_with_where() {
print_test "SELECT with WHERE clause"
local response=$(send_sql_query "SELECT id, kind FROM events WHERE kind = 1 LIMIT 5" "WHERE clause query")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"query_type":"sql_query"'; then
print_pass "WHERE clause query executed"
else
print_fail "WHERE clause query failed: $response"
fi
}
test_select_with_join() {
print_test "SELECT with JOIN"
local response=$(send_sql_query "SELECT e.id, e.kind, s.events_sent FROM events e LEFT JOIN active_subscriptions_log s ON e.id = s.subscription_id LIMIT 3" "JOIN query")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"query_type":"sql_query"'; then
print_pass "JOIN query executed"
else
print_fail "JOIN query failed: $response"
fi
}
test_select_views() {
print_test "SELECT from views"
local response=$(send_sql_query "SELECT * FROM event_kinds_view LIMIT 5" "view query")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"query_type":"sql_query"'; then
print_pass "View query executed"
else
print_fail "View query failed: $response"
fi
}
test_nonexistent_table() {
print_test "Query nonexistent table"
local response=$(send_sql_query "SELECT * FROM nonexistent_table" "nonexistent table")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"status":"error"'; then
print_pass "Nonexistent table error handled correctly"
else
print_fail "Nonexistent table error not handled: $response"
fi
}
test_invalid_syntax() {
print_test "Invalid SQL syntax"
local response=$(send_sql_query "SELECT * FROM events WHERE" "invalid syntax")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"status":"error"'; then
print_pass "Invalid syntax error handled"
else
print_fail "Invalid syntax not handled: $response"
fi
}
test_request_id_correlation() {
print_test "Request ID correlation"
local response=$(send_sql_query "SELECT * FROM events LIMIT 1" "request ID correlation")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"request_id"'; then
print_pass "Request ID included in response"
else
print_fail "Request ID missing from response: $response"
fi
}
test_response_format() {
print_test "Response format validation"
local response=$(send_sql_query "SELECT * FROM events LIMIT 1" "response format")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"query_type":"sql_query"' &&
echo "$response" | grep -q '"timestamp"' &&
echo "$response" | grep -q '"execution_time_ms"' &&
echo "$response" | grep -q '"row_count"' &&
echo "$response" | grep -q '"columns"' &&
echo "$response" | grep -q '"rows"'; then
print_pass "Response format is valid"
else
print_fail "Response format invalid: $response"
fi
}
test_empty_result() {
print_test "Empty result set"
local response=$(send_sql_query "SELECT * FROM events WHERE kind = 99999" "empty result")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"query_type":"sql_query"'; then
print_pass "Empty result handled correctly"
else
print_fail "Empty result not handled: $response"
fi
}
echo "=========================================="
echo "C-Relay SQL Query Admin API Testing Suite"
echo "=========================================="
echo "Testing SQL query functionality at $RELAY_URL"
echo ""
# Check prerequisites
check_nak
# Test basic connectivity first
echo "=== Basic Connectivity Test ==="
print_test "Basic connectivity"
response=$(send_sql_query "SELECT 1" "basic connectivity")
if [[ "$response" == *"TIMEOUT"* ]]; then
echo -e "${RED}FAILED${NC} - Cannot connect to relay at $RELAY_URL"
echo "Make sure the relay is running and accessible."
exit 1
else
print_pass "Relay connection established"
fi
echo ""
# Run test suites
echo "=== Query Validation Tests ==="
test_valid_select
test_select_count
test_blocked_insert
test_blocked_update
test_blocked_delete
test_blocked_drop
test_blocked_create
test_blocked_alter
test_blocked_pragma
echo ""
echo "=== Query Execution Tests ==="
test_select_with_where
test_select_with_join
test_select_views
test_empty_result
echo ""
echo "=== Error Handling Tests ==="
test_nonexistent_table
test_invalid_syntax
echo ""
echo "=== Response Format Tests ==="
test_request_id_correlation
test_response_format
echo ""
echo "=== Test Results ==="
echo "Total tests: $TOTAL_TESTS"
echo -e "Passed: ${GREEN}$PASSED_TESTS${NC}"
echo -e "Failed: ${RED}$FAILED_TESTS${NC}"
if [[ $FAILED_TESTS -eq 0 ]]; then
echo -e "${GREEN}✓ All SQL query tests passed!${NC}"
echo "SQL query admin API is working correctly."
exit 0
else
echo -e "${RED}✗ Some SQL query tests failed!${NC}"
echo "SQL query admin API may have issues."
exit 1
fi