Compare commits

...

7 Commits

Author SHA1 Message Date
Your Name
53f7608872 v0.7.31 - Fixed production crash by replacing in-memory subscription iteration with database queries in monitoring system 2025-10-18 18:09:13 -04:00
Your Name
838ce5b45a v0.7.30 - Update increment and push script 2025-10-18 15:04:45 -04:00
Your Name
e878b9557e v0.7.29 - Update increment and push script 2025-10-18 14:57:34 -04:00
Your Name
6638d37d6f v0.7.28 - Update increment and push script 2025-10-18 14:55:51 -04:00
Your Name
4c29e15329 v0.7.27 - Update increment and push script 2025-10-18 14:53:37 -04:00
Your Name
48890a2121 v0.7.26 - Tidy up api 2025-10-18 14:48:16 -04:00
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
28 changed files with 8029 additions and 1297 deletions

3
.gitmodules vendored
View File

@@ -4,3 +4,6 @@
[submodule "c_utils_lib"]
path = c_utils_lib
url = ssh://git@git.laantungir.net:2222/laantungir/c_utils_lib.git
[submodule "text_graph"]
path = text_graph
url = ssh://git@git.laantungir.net:2222/laantungir/text_graph.git

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

@@ -85,6 +85,8 @@ body {
.relay-info {
text-align: center;
flex: 1;
max-width: 150px;
margin: 0 auto;
}
.relay-name {
@@ -102,6 +104,8 @@ body {
cursor: pointer;
transition: border-color 0.2s ease;
background-color: var(--secondary-color);
display: inline-block;
width: fit-content;
}
.relay-pubkey-container:hover {
@@ -132,6 +136,10 @@ body {
font-size: 10px;
color: var(--primary-color);
margin-bottom: 0;
display: inline-block;
width: fit-content;
word-wrap: break-word;
overflow-wrap: break-word;
}
.header-title {
@@ -180,7 +188,7 @@ body {
padding: 8px 12px;
border-radius: var(--border-radius);
transition: background-color 0.2s ease;
margin-left: auto;
/* margin-left: auto; */
}
.admin-label {
@@ -297,6 +305,8 @@ h2 {
border-radius: var(--border-radius);
padding: 20px;
margin-bottom: 20px;
margin-left: 5px;
margin-right:5px;
}
.input-group {
@@ -545,6 +555,7 @@ button:disabled {
.inline-buttons {
display: flex;
gap: 10px;
flex-wrap: nowrap;
}
.inline-buttons button {
@@ -655,9 +666,9 @@ button:disabled {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
border-bottom: var(--border-width) solid var(--border-color);
padding-bottom: 10px;
/* margin-bottom: 15px; */
/* border-bottom: var(--border-width) solid var(--border-color); */
/* padding-bottom: 10px; */
}
.countdown-btn {
@@ -805,20 +816,296 @@ button:disabled {
transition: all 0.2s ease;
}
@media (max-width: 700px) {
body {
padding: 10px;
}
/* SQL Query Interface Styles */
.query-selector {
margin-bottom: 15px;
}
.inline-buttons {
flex-direction: column;
}
.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;
}
h1 {
font-size: 20px;
}
.query-selector select:focus {
border-color: var(--accent-color);
outline: none;
}
h2 {
font-size: 14px;
}
}
.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: 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);
}
/* Config Toggle Button Styles */
.config-toggle-btn {
width: 24px;
height: 24px;
padding: 0;
background: var(--secondary-color);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
font-family: var(--font-family);
font-size: 14px;
cursor: pointer;
margin-left: 10px;
font-weight: bold;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
/* Toggle Button Styles */
.toggle-btn {
width: auto;
min-width: 120px;
padding: 8px 12px;
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: 12px;
cursor: pointer;
transition: all 0.2s ease;
margin-left: auto;
}
.toggle-btn:hover {
border-color: var(--accent-color);
}
.toggle-btn:active {
background: var(--accent-color);
color: var(--secondary-color);
}
.config-toggle-btn:hover {
border-color: var(--accent-color);
}
.config-toggle-btn:active {
background: var(--accent-color);
color: var(--secondary-color);
}
.config-toggle-btn[data-state="true"] {
color: var(--accent-color);
}
.config-toggle-btn[data-state="false"] {
color: var(--primary-color);
}
.config-toggle-btn[data-state="indeterminate"] {
background-color: var(--muted-color);
color: var(--primary-color);
cursor: not-allowed;
border-color: var(--muted-color);
}
/* ================================
REAL-TIME EVENT RATE CHART
================================ */
.chart-container {
margin: 20px 0;
padding: 15px;
background: var(--secondary-color);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
}
#event-rate-chart {
font-family: var(--font-family);
font-size: 12px;
line-height: 1.2;
color: var(--primary-color);
background: var(--secondary-color);
padding: 20px;
overflow: hidden;
white-space: pre;
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
box-sizing: border-box;
}

View File

@@ -54,9 +54,13 @@
<div class="section flex-section" id="databaseStatisticsSection" style="display: none;">
<div class="section-header">
<h2>DATABASE STATISTICS</h2>
<button type="button" id="refresh-stats-btn" class="countdown-btn"></button>
<!-- Monitoring toggle button will be inserted here by JavaScript -->
<!-- Temporarily disable auto-refresh button for real-time monitoring -->
<!-- <button type="button" id="refresh-stats-btn" class="countdown-btn"></button> -->
</div>
<!-- Event Rate Graph Container -->
<div id="event-rate-chart"></div>
<!-- Database Overview Table -->
<div class="input-group">
@@ -77,6 +81,10 @@
<td>Total Events</td>
<td id="total-events">-</td>
</tr>
<tr>
<td>Active Subscriptions</td>
<td id="active-subscriptions">-</td>
</tr>
<tr>
<td>Oldest Event</td>
<td id="oldest-event">-</td>
@@ -164,6 +172,35 @@
</div>
<!-- SUBSCRIPTION DETAILS Section (Admin Only) -->
<div class="section flex-section" id="subscriptionDetailsSection" style="display: none;">
<div class="section-header">
<h2>ACTIVE SUBSCRIPTION DETAILS</h2>
</div>
<div class="input-group">
<div class="config-table-container">
<table class="config-table" id="subscription-details-table">
<thead>
<tr>
<th>Subscription ID</th>
<th>Client IP</th>
<th>Duration</th>
<th>Events Sent</th>
<th>Status</th>
<th>Filters</th>
</tr>
</thead>
<tbody id="subscription-details-table-body">
<tr>
<td colspan="6" style="text-align: center; font-style: italic;">No subscriptions active</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Testing Section -->
<div id="div_config" class="section flex-section" style="display: none;">
<h2>RELAY CONFIGURATION</h2>
@@ -274,6 +311,54 @@
</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">
<div class="inline-buttons">
<button type="button" id="execute-sql-btn">EXECUTE QUERY</button>
<button type="button" id="clear-sql-btn">CLEAR</button>
<button type="button" id="clear-history-btn">CLEAR HISTORY</button>
</div>
</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>
@@ -281,6 +366,8 @@
<!-- Load NOSTR_LOGIN_LITE main library -->
<!-- <script src="https://laantungir.net/nostr-login-lite/nostr-lite.js"></script> -->
<script src="/api/nostr-lite.js"></script>
<!-- Load text_graph library -->
<script src="/api/text_graph.js"></script>

File diff suppressed because it is too large Load Diff

470
api/text_graph.js Normal file
View File

@@ -0,0 +1,470 @@
/**
* ASCIIBarChart - A dynamic ASCII-based vertical bar chart renderer
*
* Creates real-time animated bar charts using monospaced characters (X)
* with automatic scaling, labels, and responsive font sizing.
*/
class ASCIIBarChart {
/**
* Create a new ASCII bar chart
* @param {string} containerId - The ID of the HTML element to render the chart in
* @param {Object} options - Configuration options
* @param {number} [options.maxHeight=20] - Maximum height of the chart in rows
* @param {number} [options.maxDataPoints=30] - Maximum number of data columns before scrolling
* @param {string} [options.title=''] - Chart title (displayed centered at top)
* @param {string} [options.xAxisLabel=''] - X-axis label (displayed centered at bottom)
* @param {string} [options.yAxisLabel=''] - Y-axis label (displayed vertically on left)
* @param {boolean} [options.autoFitWidth=true] - Automatically adjust font size to fit container width
* @param {boolean} [options.useBinMode=false] - Enable time bin mode for data aggregation
* @param {number} [options.binDuration=10000] - Duration of each time bin in milliseconds (10 seconds default)
* @param {string} [options.xAxisLabelFormat='elapsed'] - X-axis label format: 'elapsed', 'bins', 'timestamps', 'ranges'
*/
constructor(containerId, options = {}) {
this.container = document.getElementById(containerId);
this.data = [];
this.maxHeight = options.maxHeight || 20;
this.maxDataPoints = options.maxDataPoints || 30;
this.totalDataPoints = 0; // Track total number of data points added
this.title = options.title || '';
this.xAxisLabel = options.xAxisLabel || '';
this.yAxisLabel = options.yAxisLabel || '';
this.autoFitWidth = options.autoFitWidth !== false; // Default to true
// Time bin configuration
this.useBinMode = options.useBinMode !== false; // Default to true
this.binDuration = options.binDuration || 4000; // 4 seconds default
this.xAxisLabelFormat = options.xAxisLabelFormat || 'elapsed';
// Time bin data structures
this.bins = [];
this.currentBinIndex = -1;
this.binStartTime = null;
this.binCheckInterval = null;
this.chartStartTime = Date.now();
// Set up resize observer if auto-fit is enabled
if (this.autoFitWidth) {
this.resizeObserver = new ResizeObserver(() => {
this.adjustFontSize();
});
this.resizeObserver.observe(this.container);
}
// Initialize first bin if bin mode is enabled
if (this.useBinMode) {
this.initializeBins();
}
}
/**
* Add a new data point to the chart
* @param {number} value - The numeric value to add
*/
addValue(value) {
if (this.useBinMode) {
// Time bin mode: increment count in current active bin
this.checkBinRotation(); // Ensure we have an active bin
this.bins[this.currentBinIndex].count++;
this.totalDataPoints++;
} else {
// Legacy mode: add individual values
this.data.push(value);
this.totalDataPoints++;
// Keep only the most recent data points
if (this.data.length > this.maxDataPoints) {
this.data.shift();
}
}
this.render();
this.updateInfo();
}
/**
* Clear all data from the chart
*/
clear() {
this.data = [];
this.totalDataPoints = 0;
if (this.useBinMode) {
this.bins = [];
this.currentBinIndex = -1;
this.binStartTime = null;
this.initializeBins();
}
this.render();
this.updateInfo();
}
/**
* Calculate the width of the chart in characters
* @returns {number} The chart width in characters
* @private
*/
getChartWidth() {
let dataLength = this.maxDataPoints; // Always use maxDataPoints for consistent width
if (dataLength === 0) return 50; // Default width for empty chart
const yAxisPadding = this.yAxisLabel ? 2 : 0;
const yAxisNumbers = 3; // Width of Y-axis numbers
const separator = 1; // The '|' character
// const dataWidth = dataLength * 2; // Each column is 2 characters wide // TEMP: commented for no-space test
const dataWidth = dataLength; // Each column is 1 character wide // TEMP: adjusted for no-space columns
const padding = 1; // Extra padding
const totalWidth = yAxisPadding + yAxisNumbers + separator + dataWidth + padding;
// Only log when width changes
if (this.lastChartWidth !== totalWidth) {
console.log('getChartWidth changed:', { dataLength, totalWidth, previous: this.lastChartWidth });
this.lastChartWidth = totalWidth;
}
return totalWidth;
}
/**
* Adjust font size to fit container width
* @private
*/
adjustFontSize() {
if (!this.autoFitWidth) return;
const containerWidth = this.container.clientWidth;
const chartWidth = this.getChartWidth();
if (chartWidth === 0) return;
// Calculate optimal font size
// For monospace fonts, character width is approximately 0.6 * font size
// Use a slightly smaller ratio to fit more content
const charWidthRatio = 0.6;
const padding = 30; // Reduce padding to fit more content
const availableWidth = containerWidth - padding;
const optimalFontSize = Math.floor((availableWidth / chartWidth) / charWidthRatio);
// Set reasonable bounds (min 4px, max 20px)
const fontSize = Math.max(4, Math.min(20, optimalFontSize));
// Only log when font size changes
if (this.lastFontSize !== fontSize) {
console.log('fontSize changed:', { containerWidth, chartWidth, fontSize, previous: this.lastFontSize });
this.lastFontSize = fontSize;
}
this.container.style.fontSize = fontSize + 'px';
this.container.style.lineHeight = '1.0';
}
/**
* Render the chart to the container
* @private
*/
render() {
let dataToRender = [];
let maxValue = 0;
let minValue = 0;
let valueRange = 0;
if (this.useBinMode) {
// Bin mode: render bin counts
if (this.bins.length === 0) {
this.container.textContent = 'No data yet. Click Start to begin.';
return;
}
// Always create a fixed-length array filled with 0s, then overlay actual bin data
dataToRender = new Array(this.maxDataPoints).fill(0);
// Overlay actual bin data (most recent bins, reversed for left-to-right display)
const startIndex = Math.max(0, this.bins.length - this.maxDataPoints);
const recentBins = this.bins.slice(startIndex);
// Reverse the bins so most recent is on the left, and overlay onto the fixed array
recentBins.reverse().forEach((bin, index) => {
if (index < this.maxDataPoints) {
dataToRender[index] = bin.count;
}
});
console.log('render() dataToRender:', dataToRender, 'bins length:', this.bins.length);
maxValue = Math.max(...dataToRender);
minValue = Math.min(...dataToRender);
valueRange = maxValue - minValue;
} else {
// Legacy mode: render individual values
if (this.data.length === 0) {
this.container.textContent = 'No data yet. Click Start to begin.';
return;
}
dataToRender = this.data;
maxValue = Math.max(...this.data);
minValue = Math.min(...this.data);
valueRange = maxValue - minValue;
}
let output = '';
const scale = this.maxHeight;
// Calculate scaling factor: each X represents at least 1 count
const maxCount = Math.max(...dataToRender);
const scaleFactor = Math.max(1, Math.ceil(maxCount / scale)); // 1 X = scaleFactor counts
const scaledMax = Math.ceil(maxCount / scaleFactor) * scaleFactor;
// Calculate Y-axis label width (for vertical text)
const yLabelWidth = this.yAxisLabel ? 2 : 0;
const yAxisPadding = this.yAxisLabel ? ' ' : '';
// Add title if provided (centered)
if (this.title) {
// const chartWidth = 4 + this.maxDataPoints * 2; // Y-axis numbers + data columns // TEMP: commented for no-space test
const chartWidth = 4 + this.maxDataPoints; // Y-axis numbers + data columns // TEMP: adjusted for no-space columns
const titlePadding = Math.floor((chartWidth - this.title.length) / 2);
output += yAxisPadding + ' '.repeat(Math.max(0, titlePadding)) + this.title + '\n\n';
}
// Draw from top to bottom
for (let row = scale; row > 0; row--) {
let line = '';
// Add vertical Y-axis label character
if (this.yAxisLabel) {
const L = this.yAxisLabel.length;
const startRow = Math.floor((scale - L) / 2) + 1;
const relativeRow = scale - row + 1; // 1 at top, scale at bottom
if (relativeRow >= startRow && relativeRow < startRow + L) {
const labelIndex = relativeRow - startRow;
line += this.yAxisLabel[labelIndex] + ' ';
} else {
line += ' ';
}
}
// Calculate the actual count value this row represents (0 at bottom, increasing upward)
const rowCount = (row - 1) * scaleFactor;
// Add Y-axis label (show actual count values)
line += String(rowCount).padStart(3, ' ') + ' |';
// Draw each column
for (let i = 0; i < dataToRender.length; i++) {
const count = dataToRender[i];
const scaledHeight = Math.ceil(count / scaleFactor);
if (scaledHeight >= row) {
// line += ' X'; // TEMP: commented out space between columns
line += 'X'; // TEMP: no space between columns
} else {
// line += ' '; // TEMP: commented out space between columns
line += ' '; // TEMP: single space for empty columns
}
}
output += line + '\n';
}
// Draw X-axis
// output += yAxisPadding + ' +' + '-'.repeat(this.maxDataPoints * 2) + '\n'; // TEMP: commented out for no-space test
output += yAxisPadding + ' +' + '-'.repeat(this.maxDataPoints) + '\n'; // TEMP: back to original length
// Draw X-axis labels based on mode and format
let xAxisLabels = yAxisPadding + ' '; // Initial padding to align with X-axis
// Determine label interval (every 5 columns)
const labelInterval = 5;
// Generate all labels first and store in array
let labels = [];
for (let i = 0; i < this.maxDataPoints; i++) {
if (i % labelInterval === 0) {
let label = '';
if (this.useBinMode) {
// For bin mode, show labels for all possible positions
// i=0 is leftmost (most recent), i=maxDataPoints-1 is rightmost (oldest)
const elapsedSec = (i * this.binDuration) / 1000;
// Format with appropriate precision for sub-second bins
if (this.binDuration < 1000) {
// Show decimal seconds for sub-second bins
label = elapsedSec.toFixed(1) + 's';
} else {
// Show whole seconds for 1+ second bins
label = String(Math.round(elapsedSec)) + 's';
}
} else {
// For legacy mode, show data point numbers
const startIndex = Math.max(1, this.totalDataPoints - this.maxDataPoints + 1);
label = String(startIndex + i);
}
labels.push(label);
}
}
// Build the label string with calculated spacing
for (let i = 0; i < labels.length; i++) {
const label = labels[i];
xAxisLabels += label;
// Add spacing: labelInterval - label.length (except for last label)
if (i < labels.length - 1) {
const spacing = labelInterval - label.length;
xAxisLabels += ' '.repeat(spacing);
}
}
// Ensure the label line extends to match the X-axis dash line length
// The dash line is this.maxDataPoints characters long, starting after " +"
const dashLineLength = this.maxDataPoints;
const minLabelLineLength = yAxisPadding.length + 4 + dashLineLength; // 4 for " "
if (xAxisLabels.length < minLabelLineLength) {
xAxisLabels += ' '.repeat(minLabelLineLength - xAxisLabels.length);
}
output += xAxisLabels + '\n';
// Add X-axis label if provided
if (this.xAxisLabel) {
// const labelPadding = Math.floor((this.maxDataPoints * 2 - this.xAxisLabel.length) / 2); // TEMP: commented for no-space test
const labelPadding = Math.floor((this.maxDataPoints - this.xAxisLabel.length) / 2); // TEMP: adjusted for no-space columns
output += '\n' + yAxisPadding + ' ' + ' '.repeat(Math.max(0, labelPadding)) + this.xAxisLabel + '\n';
}
this.container.textContent = output;
// Adjust font size to fit width (only once at initialization)
if (this.autoFitWidth) {
this.adjustFontSize();
}
// Update the external info display
if (this.useBinMode) {
const binCounts = this.bins.map(bin => bin.count);
const scaleFactor = Math.max(1, Math.ceil(maxValue / scale));
document.getElementById('values').textContent = `[${dataToRender.join(', ')}]`;
document.getElementById('max-value').textContent = maxValue;
document.getElementById('scale').textContent = `Min: ${minValue}, Max: ${maxValue}, 1X=${scaleFactor} counts`;
} else {
document.getElementById('values').textContent = `[${this.data.join(', ')}]`;
document.getElementById('max-value').textContent = maxValue;
document.getElementById('scale').textContent = `Min: ${minValue}, Max: ${maxValue}, Height: ${scale}`;
}
}
/**
* Update the info display
* @private
*/
updateInfo() {
if (this.useBinMode) {
const totalCount = this.bins.reduce((sum, bin) => sum + bin.count, 0);
document.getElementById('count').textContent = totalCount;
} else {
document.getElementById('count').textContent = this.data.length;
}
}
/**
* Initialize the bin system
* @private
*/
initializeBins() {
this.bins = [];
this.currentBinIndex = -1;
this.binStartTime = null;
this.chartStartTime = Date.now();
// Create first bin
this.rotateBin();
// Set up automatic bin rotation check
this.binCheckInterval = setInterval(() => {
this.checkBinRotation();
}, 100); // Check every 100ms for responsiveness
}
/**
* Check if current bin should rotate and create new bin if needed
* @private
*/
checkBinRotation() {
if (!this.useBinMode || !this.binStartTime) return;
const now = Date.now();
if ((now - this.binStartTime) >= this.binDuration) {
this.rotateBin();
}
}
/**
* Rotate to a new bin, finalizing the current one
*/
rotateBin() {
// Finalize current bin if it exists
if (this.currentBinIndex >= 0) {
this.bins[this.currentBinIndex].isActive = false;
}
// Create new bin
const newBin = {
startTime: Date.now(),
count: 0,
isActive: true
};
this.bins.push(newBin);
this.currentBinIndex = this.bins.length - 1;
this.binStartTime = newBin.startTime;
// Keep only the most recent bins
if (this.bins.length > this.maxDataPoints) {
this.bins.shift();
this.currentBinIndex--;
}
// Ensure currentBinIndex points to the last bin (the active one)
this.currentBinIndex = this.bins.length - 1;
// Force a render to update the display immediately
this.render();
this.updateInfo();
}
/**
* Format X-axis label for a bin based on the configured format
* @param {number} binIndex - Index of the bin
* @returns {string} Formatted label
* @private
*/
formatBinLabel(binIndex) {
const bin = this.bins[binIndex];
if (!bin) return ' ';
switch (this.xAxisLabelFormat) {
case 'bins':
return String(binIndex + 1).padStart(2, ' ');
case 'timestamps':
const time = new Date(bin.startTime);
return time.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).replace(/:/g, '');
case 'ranges':
const startSec = Math.floor((bin.startTime - this.chartStartTime) / 1000);
const endSec = startSec + Math.floor(this.binDuration / 1000);
return `${startSec}-${endSec}`;
case 'elapsed':
default:
// For elapsed time, always show time relative to the first bin (index 0)
// This keeps the leftmost label as 0s and increases to the right
const firstBinTime = this.bins[0] ? this.bins[0].startTime : this.chartStartTime;
const elapsedSec = Math.floor((bin.startTime - firstBinTime) / 1000);
return String(elapsedSec).padStart(2, ' ') + 's';
}
}
}

View File

@@ -0,0 +1,601 @@
# Simplified Monitoring Implementation Plan
## Kind 34567 Event Kind Distribution Reporting
**Date:** 2025-10-16
**Status:** Implementation Ready
---
## Overview
Simplified real-time monitoring system that:
- Reports event kind distribution (which includes total event count)
- Uses kind 34567 addressable events with `d=event_kinds`
- Controlled by two config variables
- Enabled on-demand when admin logs in
- Uses simple throttling to prevent performance impact
---
## Configuration Variables
### Database Config Table
Add two new configuration keys:
```sql
INSERT INTO config (key, value, data_type, description, category) VALUES
('kind_34567_reporting_enabled', 'false', 'boolean',
'Enable/disable kind 34567 event kind distribution reporting', 'monitoring'),
('kind_34567_reporting_throttling_sec', '5', 'integer',
'Minimum seconds between kind 34567 reports (throttling)', 'monitoring');
```
### Configuration Access
```c
// In src/monitoring.c or src/api.c
int is_monitoring_enabled(void) {
return get_config_bool("kind_34567_reporting_enabled", 0);
}
int get_monitoring_throttle_seconds(void) {
return get_config_int("kind_34567_reporting_throttling_sec", 5);
}
```
---
## Event Structure
### Kind 34567 Event Format
```json
{
"id": "<event_id>",
"pubkey": "<relay_pubkey>",
"created_at": 1697123456,
"kind": 34567,
"content": "{\"data_type\":\"event_kinds\",\"timestamp\":1697123456,\"data\":{\"total_events\":125000,\"distribution\":[{\"kind\":1,\"count\":45000,\"percentage\":36.0},{\"kind\":3,\"count\":12500,\"percentage\":10.0}]}}",
"tags": [
["d", "event_kinds"],
["relay", "<relay_pubkey>"]
],
"sig": "<signature>"
}
```
### Content JSON Structure
```json
{
"data_type": "event_kinds",
"timestamp": 1697123456,
"data": {
"total_events": 125000,
"distribution": [
{
"kind": 1,
"count": 45000,
"percentage": 36.0
},
{
"kind": 3,
"count": 12500,
"percentage": 10.0
}
]
},
"metadata": {
"query_time_ms": 18
}
}
```
---
## Implementation
### File Structure
```
src/
monitoring.h # New file - monitoring system header
monitoring.c # New file - monitoring implementation
main.c # Modified - add trigger hook
config.c # Modified - add config keys (or use migration)
```
### 1. Header File: `src/monitoring.h`
```c
#ifndef MONITORING_H
#define MONITORING_H
#include <time.h>
#include <cjson/cJSON.h>
// Initialize monitoring system
int init_monitoring_system(void);
// Cleanup monitoring system
void cleanup_monitoring_system(void);
// Called when an event is stored (from main.c)
void monitoring_on_event_stored(void);
// Enable/disable monitoring (called from admin API)
int set_monitoring_enabled(int enabled);
// Get monitoring status
int is_monitoring_enabled(void);
// Get throttle interval
int get_monitoring_throttle_seconds(void);
#endif /* MONITORING_H */
```
### 2. Implementation: `src/monitoring.c`
```c
#include "monitoring.h"
#include "config.h"
#include "debug.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
#include <sqlite3.h>
#include <string.h>
#include <time.h>
// External references
extern sqlite3* g_db;
extern int broadcast_event_to_subscriptions(cJSON* event);
extern int store_event(cJSON* event);
extern const char* get_config_value(const char* key);
extern int get_config_bool(const char* key, int default_value);
extern int get_config_int(const char* key, int default_value);
extern char* get_relay_private_key(void);
// Throttling state
static time_t last_report_time = 0;
// Initialize monitoring system
int init_monitoring_system(void) {
DEBUG_LOG("Monitoring system initialized");
last_report_time = 0;
return 0;
}
// Cleanup monitoring system
void cleanup_monitoring_system(void) {
DEBUG_LOG("Monitoring system cleaned up");
}
// Check if monitoring is enabled
int is_monitoring_enabled(void) {
return get_config_bool("kind_34567_reporting_enabled", 0);
}
// Get throttle interval
int get_monitoring_throttle_seconds(void) {
return get_config_int("kind_34567_reporting_throttling_sec", 5);
}
// Enable/disable monitoring
int set_monitoring_enabled(int enabled) {
// Update config table
const char* value = enabled ? "true" : "false";
// This would call update_config_in_table() or similar
// For now, assume we have a function to update config
extern int update_config_in_table(const char* key, const char* value);
return update_config_in_table("kind_34567_reporting_enabled", value);
}
// Query event kind distribution from database
static char* query_event_kind_distribution(void) {
if (!g_db) {
DEBUG_ERROR("Database not available for monitoring query");
return NULL;
}
struct timespec start_time;
clock_gettime(CLOCK_MONOTONIC, &start_time);
// Query total events
sqlite3_stmt* stmt;
int total_events = 0;
if (sqlite3_prepare_v2(g_db, "SELECT COUNT(*) FROM events", -1, &stmt, NULL) == SQLITE_OK) {
if (sqlite3_step(stmt) == SQLITE_ROW) {
total_events = sqlite3_column_int(stmt, 0);
}
sqlite3_finalize(stmt);
}
// Query kind distribution
cJSON* response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "data_type", "event_kinds");
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
cJSON* data = cJSON_CreateObject();
cJSON_AddNumberToObject(data, "total_events", total_events);
cJSON* distribution = cJSON_CreateArray();
const char* sql =
"SELECT kind, COUNT(*) as count, "
"ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM events), 2) as percentage "
"FROM events GROUP BY kind ORDER BY count DESC";
if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL) == SQLITE_OK) {
while (sqlite3_step(stmt) == SQLITE_ROW) {
cJSON* kind_obj = cJSON_CreateObject();
cJSON_AddNumberToObject(kind_obj, "kind", sqlite3_column_int(stmt, 0));
cJSON_AddNumberToObject(kind_obj, "count", sqlite3_column_int64(stmt, 1));
cJSON_AddNumberToObject(kind_obj, "percentage", sqlite3_column_double(stmt, 2));
cJSON_AddItemToArray(distribution, kind_obj);
}
sqlite3_finalize(stmt);
}
cJSON_AddItemToObject(data, "distribution", distribution);
cJSON_AddItemToObject(response, "data", data);
// Calculate query time
struct timespec end_time;
clock_gettime(CLOCK_MONOTONIC, &end_time);
double query_time_ms = (end_time.tv_sec - start_time.tv_sec) * 1000.0 +
(end_time.tv_nsec - start_time.tv_nsec) / 1000000.0;
cJSON* metadata = cJSON_CreateObject();
cJSON_AddNumberToObject(metadata, "query_time_ms", query_time_ms);
cJSON_AddItemToObject(response, "metadata", metadata);
char* json_string = cJSON_Print(response);
cJSON_Delete(response);
return json_string;
}
// Generate and broadcast kind 34567 event
static int generate_monitoring_event(const char* json_content) {
if (!json_content) return -1;
// Get relay keys
const char* relay_pubkey = get_config_value("relay_pubkey");
char* relay_privkey_hex = get_relay_private_key();
if (!relay_pubkey || !relay_privkey_hex) {
if (relay_privkey_hex) free(relay_privkey_hex);
DEBUG_ERROR("Could not get relay keys for monitoring event");
return -1;
}
// Convert relay private key to bytes
unsigned char relay_privkey[32];
if (nostr_hex_to_bytes(relay_privkey_hex, relay_privkey, sizeof(relay_privkey)) != 0) {
free(relay_privkey_hex);
DEBUG_ERROR("Failed to convert relay private key");
return -1;
}
free(relay_privkey_hex);
// Create tags array
cJSON* tags = cJSON_CreateArray();
// d tag for addressable event
cJSON* d_tag = cJSON_CreateArray();
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
cJSON_AddItemToArray(d_tag, cJSON_CreateString("event_kinds"));
cJSON_AddItemToArray(tags, d_tag);
// relay tag
cJSON* relay_tag = cJSON_CreateArray();
cJSON_AddItemToArray(relay_tag, cJSON_CreateString("relay"));
cJSON_AddItemToArray(relay_tag, cJSON_CreateString(relay_pubkey));
cJSON_AddItemToArray(tags, relay_tag);
// Create and sign event
cJSON* event = nostr_create_and_sign_event(
34567, // kind
json_content, // content
tags, // tags
relay_privkey, // private key
time(NULL) // timestamp
);
if (!event) {
DEBUG_ERROR("Failed to create and sign monitoring event");
return -1;
}
// Broadcast to subscriptions
broadcast_event_to_subscriptions(event);
// Store in database
int result = store_event(event);
cJSON_Delete(event);
return result;
}
// Called when an event is stored
void monitoring_on_event_stored(void) {
// Check if monitoring is enabled
if (!is_monitoring_enabled()) {
return;
}
// Check throttling
time_t now = time(NULL);
int throttle_seconds = get_monitoring_throttle_seconds();
if (now - last_report_time < throttle_seconds) {
return; // Too soon, skip this update
}
// Query event kind distribution
char* json_content = query_event_kind_distribution();
if (!json_content) {
DEBUG_ERROR("Failed to query event kind distribution");
return;
}
// Generate and broadcast monitoring event
int result = generate_monitoring_event(json_content);
free(json_content);
if (result == 0) {
last_report_time = now;
DEBUG_LOG("Generated kind 34567 monitoring event");
} else {
DEBUG_ERROR("Failed to generate monitoring event");
}
}
```
### 3. Integration: Modify `src/main.c`
Add monitoring hook to event storage:
```c
// At top of file
#include "monitoring.h"
// In main() function, after init_database()
if (init_monitoring_system() != 0) {
DEBUG_WARN("Failed to initialize monitoring system");
// Continue anyway - monitoring is optional
}
// In store_event() function, after successful storage
int store_event(cJSON* event) {
// ... existing code ...
if (rc != SQLITE_DONE) {
// ... error handling ...
}
free(tags_json);
// Trigger monitoring update
monitoring_on_event_stored();
return 0;
}
// In cleanup section of main()
cleanup_monitoring_system();
```
### 4. Admin API: Enable/Disable Monitoring
Add admin command to enable monitoring (in `src/dm_admin.c` or `src/api.c`):
```c
// Handle admin command to enable monitoring
if (strcmp(command, "enable_monitoring") == 0) {
set_monitoring_enabled(1);
send_nip17_response(sender_pubkey,
"✅ Kind 34567 monitoring enabled",
error_msg, sizeof(error_msg));
return 0;
}
// Handle admin command to disable monitoring
if (strcmp(command, "disable_monitoring") == 0) {
set_monitoring_enabled(0);
send_nip17_response(sender_pubkey,
"🔴 Kind 34567 monitoring disabled",
error_msg, sizeof(error_msg));
return 0;
}
// Handle admin command to set throttle interval
if (strncmp(command, "set_monitoring_throttle ", 24) == 0) {
int seconds = atoi(command + 24);
if (seconds >= 1 && seconds <= 3600) {
char value[16];
snprintf(value, sizeof(value), "%d", seconds);
update_config_in_table("kind_34567_reporting_throttling_sec", value);
char response[128];
snprintf(response, sizeof(response),
"✅ Monitoring throttle set to %d seconds", seconds);
send_nip17_response(sender_pubkey, response, error_msg, sizeof(error_msg));
}
return 0;
}
```
---
## Frontend Integration
### Admin Dashboard Subscription
```javascript
// When admin logs in to dashboard
async function enableMonitoring() {
// Send admin command to enable monitoring
await sendAdminCommand(['enable_monitoring']);
// Subscribe to kind 34567 events
const subscription = {
kinds: [34567],
authors: [relayPubkey],
"#d": ["event_kinds"]
};
relay.subscribe([subscription], {
onevent: (event) => {
handleMonitoringEvent(event);
}
});
}
// Handle incoming monitoring events
function handleMonitoringEvent(event) {
const content = JSON.parse(event.content);
if (content.data_type === 'event_kinds') {
updateEventKindsChart(content.data);
updateTotalEventsDisplay(content.data.total_events);
}
}
// When admin logs out or closes dashboard
async function disableMonitoring() {
await sendAdminCommand(['disable_monitoring']);
}
```
### Display Event Kind Distribution
```javascript
function updateEventKindsChart(data) {
const { total_events, distribution } = data;
// Update total events display
document.getElementById('total-events').textContent =
total_events.toLocaleString();
// Update chart/table with distribution
const tableBody = document.getElementById('kind-distribution-table');
tableBody.innerHTML = '';
distribution.forEach(item => {
const row = document.createElement('tr');
row.innerHTML = `
<td>Kind ${item.kind}</td>
<td>${item.count.toLocaleString()}</td>
<td>${item.percentage}%</td>
`;
tableBody.appendChild(row);
});
}
```
---
## Configuration Migration
### Add to Schema or Migration Script
```sql
-- Add monitoring configuration
INSERT INTO config (key, value, data_type, description, category) VALUES
('kind_34567_reporting_enabled', 'false', 'boolean',
'Enable/disable kind 34567 event kind distribution reporting', 'monitoring'),
('kind_34567_reporting_throttling_sec', '5', 'integer',
'Minimum seconds between kind 34567 reports (throttling)', 'monitoring');
```
Or add to existing config initialization in `src/config.c`.
---
## Testing
### 1. Enable Monitoring
```bash
# Via admin command (NIP-17 DM)
echo '["enable_monitoring"]' | nak event --kind 14 --content - ws://localhost:8888
```
### 2. Subscribe to Monitoring Events
```bash
# Subscribe to kind 34567 events
nak req --kinds 34567 --authors <relay_pubkey> ws://localhost:8888
```
### 3. Generate Events
```bash
# Send some test events to trigger monitoring
for i in {1..10}; do
nak event -c "Test event $i" ws://localhost:8888
sleep 1
done
```
### 4. Verify Monitoring Events
You should see kind 34567 events every 5 seconds (or configured throttle interval) with event kind distribution.
---
## Performance Impact
### With 3 events/second (relay.damus.io scale)
**Query execution**:
- Frequency: Every 5 seconds (throttled)
- Query time: ~700ms (for 1M events)
- Overhead: 700ms / 5000ms = 14% (acceptable)
**Per-event overhead**:
- Check if enabled: < 0.01ms
- Check throttle: < 0.01ms
- Total: < 0.02ms per event (negligible)
**Overall impact**: < 1% on event processing, 14% on query thread (separate from event processing)
---
## Future Enhancements
Once this is working, easy to add:
1. **More data types**: Add `d=connections`, `d=subscriptions`, etc.
2. **Materialized counters**: Optimize queries for very large databases
3. **Historical data**: Store monitoring events for trending
4. **Alerts**: Trigger on thresholds (e.g., > 90% capacity)
---
## Summary
This simplified plan provides:
**Single data type**: Event kind distribution (includes total events)
**Two config variables**: Enable/disable and throttle control
**On-demand activation**: Enabled when admin logs in
**Simple throttling**: Prevents performance impact
**Clean implementation**: ~200 lines of code
**Easy to extend**: Add more data types later
**Estimated implementation time**: 4-6 hours
**Files to create/modify**:
- Create: `src/monitoring.h` (~30 lines)
- Create: `src/monitoring.c` (~200 lines)
- Modify: `src/main.c` (~10 lines)
- Modify: `src/config.c` or migration (~5 lines)
- Modify: `src/dm_admin.c` or `src/api.c` (~30 lines)
- Create: `api/monitoring.js` (frontend, ~100 lines)
**Total new code**: ~375 lines

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,325 @@
# Relay Traffic Measurement Guide
## Measuring Real-World Relay Traffic
To validate our performance assumptions, here are commands to measure actual event rates from live relays.
---
## Command: Count Events Over 1 Minute
### Basic Command
```bash
# Count events from relay.damus.io over 60 seconds
timeout 60 nak req -s $(date +%s) --stream wss://relay.damus.io | wc -l
```
This will:
1. Subscribe to all new events (`-s $(date +%s)` = since now)
2. Stream for 60 seconds (`timeout 60`)
3. Count the lines (each line = 1 event)
### With Event Rate Display
```bash
# Show events per second in real-time
timeout 60 nak req -s $(date +%s) --stream wss://relay.damus.io | \
pv -l -i 1 -r > /dev/null
```
This displays:
- Total events received
- Current rate (events/second)
- Average rate
### With Detailed Statistics
```bash
# Count events and calculate statistics
echo "Measuring relay traffic for 60 seconds..."
START=$(date +%s)
COUNT=$(timeout 60 nak req -s $START --stream wss://relay.damus.io | wc -l)
END=$(date +%s)
DURATION=$((END - START))
echo "Results:"
echo " Total events: $COUNT"
echo " Duration: ${DURATION}s"
echo " Events/second: $(echo "scale=2; $COUNT / $DURATION" | bc)"
echo " Events/minute: $COUNT"
```
### With Event Kind Distribution
```bash
# Count events by kind over 60 seconds
timeout 60 nak req -s $(date +%s) --stream wss://relay.damus.io | \
jq -r '.kind' | \
sort | uniq -c | sort -rn
```
Output example:
```
45 1 # 45 text notes
12 3 # 12 contact lists
8 7 # 8 reactions
3 6 # 3 reposts
```
### With Timestamp Analysis
```bash
# Show event timestamps and calculate intervals
timeout 60 nak req -s $(date +%s) --stream wss://relay.damus.io | \
jq -r '.created_at' | \
awk 'NR>1 {print $1-prev} {prev=$1}' | \
awk '{sum+=$1; count++} END {
print "Average interval:", sum/count, "seconds"
print "Events per second:", count/sum
}'
```
---
## Testing Multiple Relays
### Compare Traffic Across Relays
```bash
#!/bin/bash
# test_relay_traffic.sh
RELAYS=(
"wss://relay.damus.io"
"wss://nos.lol"
"wss://relay.nostr.band"
"wss://nostr.wine"
)
DURATION=60
echo "Measuring relay traffic for ${DURATION} seconds..."
echo ""
for relay in "${RELAYS[@]}"; do
echo "Testing: $relay"
count=$(timeout $DURATION nak req -s $(date +%s) --stream "$relay" 2>/dev/null | wc -l)
rate=$(echo "scale=2; $count / $DURATION" | bc)
echo " Events: $count"
echo " Rate: ${rate}/sec"
echo ""
done
```
---
## Expected Results (Based on Real Measurements)
### relay.damus.io (Large Public Relay)
- **Expected rate**: 0.5-2 events/second
- **60-second count**: 30-120 events
- **Peak times**: Higher during US daytime hours
### nos.lol (Medium Public Relay)
- **Expected rate**: 0.2-0.8 events/second
- **60-second count**: 12-48 events
### Personal/Small Relays
- **Expected rate**: 0.01-0.1 events/second
- **60-second count**: 1-6 events
---
## Using Results to Validate Performance Assumptions
After measuring your relay's traffic:
1. **Calculate average events/second**:
```
events_per_second = total_events / 60
```
2. **Estimate query overhead**:
```
# For 100k event database:
query_time = 70ms
overhead_percentage = (query_time * events_per_second) / 1000 * 100
# Example: 0.5 events/sec
overhead = (70 * 0.5) / 1000 * 100 = 3.5%
```
3. **Determine if optimization needed**:
- < 5% overhead: No optimization needed
- 5-20% overhead: Consider 1-second throttling
- > 20% overhead: Use materialized counters
---
## Real-Time Monitoring During Development
### Monitor Your Own Relay
```bash
# Watch events in real-time with count
nak req -s $(date +%s) --stream ws://localhost:8888 | \
awk '{count++; print count, $0}'
```
### Monitor with Event Details
```bash
# Show event kind and pubkey for each event
nak req -s $(date +%s) --stream ws://localhost:8888 | \
jq -r '"[\(.kind)] \(.pubkey[0:8])... \(.content[0:50])"'
```
### Continuous Traffic Monitoring
```bash
# Monitor traffic in 10-second windows
while true; do
echo "=== $(date) ==="
count=$(timeout 10 nak req -s $(date +%s) --stream ws://localhost:8888 | wc -l)
rate=$(echo "scale=2; $count / 10" | bc)
echo "Events: $count (${rate}/sec)"
sleep 1
done
```
---
## Performance Testing Commands
### Simulate Load
```bash
# Send test events to measure query performance
for i in {1..100}; do
nak event -c "Test event $i" ws://localhost:8888
sleep 0.1 # 10 events/second
done
```
### Measure Query Response Time
```bash
# Time how long queries take with current database
time sqlite3 your_relay.db "SELECT COUNT(*) FROM events"
time sqlite3 your_relay.db "SELECT kind, COUNT(*) FROM events GROUP BY kind"
```
---
## Automated Traffic Analysis Script
Save this as `analyze_relay_traffic.sh`:
```bash
#!/bin/bash
# Comprehensive relay traffic analysis
RELAY="${1:-ws://localhost:8888}"
DURATION="${2:-60}"
echo "Analyzing relay: $RELAY"
echo "Duration: ${DURATION} seconds"
echo ""
# Collect events
TMPFILE=$(mktemp)
timeout $DURATION nak req -s $(date +%s) --stream "$RELAY" > "$TMPFILE" 2>/dev/null
# Calculate statistics
TOTAL=$(wc -l < "$TMPFILE")
RATE=$(echo "scale=2; $TOTAL / $DURATION" | bc)
echo "=== Traffic Statistics ==="
echo "Total events: $TOTAL"
echo "Events/second: $RATE"
echo "Events/minute: $(echo "$TOTAL * 60 / $DURATION" | bc)"
echo ""
echo "=== Event Kind Distribution ==="
jq -r '.kind' "$TMPFILE" | sort | uniq -c | sort -rn | head -10
echo ""
echo "=== Top Publishers ==="
jq -r '.pubkey[0:16]' "$TMPFILE" | sort | uniq -c | sort -rn | head -5
echo ""
echo "=== Performance Estimate ==="
echo "For 100k event database:"
echo " Query time: ~70ms"
echo " Overhead: $(echo "scale=2; 70 * $RATE / 10" | bc)%"
echo ""
# Cleanup
rm "$TMPFILE"
```
Usage:
```bash
chmod +x analyze_relay_traffic.sh
./analyze_relay_traffic.sh wss://relay.damus.io 60
```
---
## Interpreting Results
### Low Traffic (< 0.1 events/sec)
- **Typical for**: Personal relays, small communities
- **Recommendation**: Trigger on every event, no optimization
- **Expected overhead**: < 1%
### Medium Traffic (0.1-0.5 events/sec)
- **Typical for**: Medium public relays
- **Recommendation**: Trigger on every event, consider throttling if database > 100k
- **Expected overhead**: 1-5%
### High Traffic (0.5-2 events/sec)
- **Typical for**: Large public relays
- **Recommendation**: Use 1-second throttling
- **Expected overhead**: 5-20% without throttling, < 1% with throttling
### Very High Traffic (> 2 events/sec)
- **Typical for**: Major public relays (rare)
- **Recommendation**: Use materialized counters
- **Expected overhead**: > 20% without optimization
---
## Continuous Monitoring in Production
### Add to Relay Startup
```bash
# In your relay startup script
echo "Starting traffic monitoring..."
nohup bash -c 'while true; do
count=$(timeout 60 nak req -s $(date +%s) --stream ws://localhost:8888 2>/dev/null | wc -l)
echo "$(date +%Y-%m-%d\ %H:%M:%S) - Events/min: $count" >> traffic.log
done' &
```
### Analyze Historical Traffic
```bash
# View traffic trends
cat traffic.log | awk '{print $4}' | \
awk '{sum+=$1; count++} END {print "Average:", sum/count, "events/min"}'
```
---
## Conclusion
Use these commands to:
1. ✅ Measure real-world traffic on your relay
2. ✅ Validate performance assumptions
3. ✅ Determine if optimization is needed
4. ✅ Monitor traffic trends over time
**Remember**: Most relays will measure < 1 event/second, making the simple "trigger on every event" approach perfectly viable.

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

@@ -121,10 +121,43 @@ increment_version() {
print_status "Current version: $LATEST_TAG"
print_status "New version: $NEW_VERSION"
# Update version in src/main.h
update_version_in_header "$NEW_VERSION" "$MAJOR" "${NEW_MINOR:-$MINOR}" "${NEW_PATCH:-$PATCH}"
# Export for use in other functions
export NEW_VERSION
}
# Function to update version macros in src/main.h
update_version_in_header() {
local new_version="$1"
local major="$2"
local minor="$3"
local patch="$4"
print_status "Updating version in src/main.h..."
# Check if src/main.h exists
if [[ ! -f "src/main.h" ]]; then
print_error "src/main.h not found"
exit 1
fi
# Update VERSION macro
sed -i "s/#define VERSION \".*\"/#define VERSION \"$new_version\"/" src/main.h
# Update VERSION_MAJOR macro
sed -i "s/#define VERSION_MAJOR [0-9]\+/#define VERSION_MAJOR $major/" src/main.h
# Update VERSION_MINOR macro
sed -i "s/#define VERSION_MINOR .*/#define VERSION_MINOR $minor/" src/main.h
# Update VERSION_PATCH macro
sed -i "s/#define VERSION_PATCH [0-9]\+/#define VERSION_PATCH $patch/" src/main.h
print_success "Updated version in src/main.h to $new_version"
}
# Function to commit and push changes
git_commit_and_push() {
print_status "Preparing git commit..."

View File

@@ -1 +1 @@
343475
3579819

2183
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,56 @@ 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);
// Monitoring system functions
int init_monitoring_system(void);
void cleanup_monitoring_system(void);
void monitoring_on_event_stored(void);
int set_monitoring_enabled(int enabled);
int is_monitoring_enabled(void);
int get_monitoring_throttle_seconds(void);
#endif // API_H

View File

@@ -3,6 +3,19 @@
#include "debug.h"
#include "default_config_event.h"
#include "dm_admin.h"
// Undefine VERSION macros before including nostr_core.h to avoid redefinition warnings
// This must come AFTER default_config_event.h so that RELAY_VERSION macro expansion works correctly
#ifdef VERSION
#undef VERSION
#endif
#ifdef VERSION_MINOR
#undef VERSION_MINOR
#endif
#ifdef VERSION_PATCH
#undef VERSION_PATCH
#endif
#include "../nostr_core_lib/nostr_core/nostr_core.h"
#include <stdio.h>
#include <stdlib.h>
@@ -4099,6 +4112,37 @@ int populate_all_config_values_atomic(const char* admin_pubkey, const char* rela
return -1;
}
// Insert monitoring system config entries
sqlite3_reset(stmt);
sqlite3_bind_text(stmt, 1, "kind_34567_reporting_enabled", -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 2, "false", -1, SQLITE_STATIC); // boolean, default false
sqlite3_bind_text(stmt, 3, "boolean", -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 4, "Enable real-time monitoring event generation", -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 5, "monitoring", -1, SQLITE_STATIC);
sqlite3_bind_int(stmt, 6, 0); // does not require restart
rc = sqlite3_step(stmt);
if (rc != SQLITE_DONE) {
DEBUG_ERROR("Failed to insert kind_34567_reporting_enabled: %s", sqlite3_errmsg(g_db));
sqlite3_finalize(stmt);
sqlite3_exec(g_db, "ROLLBACK;", NULL, NULL, NULL);
return -1;
}
sqlite3_reset(stmt);
sqlite3_bind_text(stmt, 1, "kind_34567_reporting_throttling_sec", -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 2, "5", -1, SQLITE_STATIC); // integer, default 5 seconds
sqlite3_bind_text(stmt, 3, "integer", -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 4, "Minimum seconds between monitoring event reports", -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 5, "monitoring", -1, SQLITE_STATIC);
sqlite3_bind_int(stmt, 6, 0); // does not require restart
rc = sqlite3_step(stmt);
if (rc != SQLITE_DONE) {
DEBUG_ERROR("Failed to insert kind_34567_reporting_throttling_sec: %s", sqlite3_errmsg(g_db));
sqlite3_finalize(stmt);
sqlite3_exec(g_db, "ROLLBACK;", NULL, NULL, NULL);
return -1;
}
sqlite3_finalize(stmt);
// Commit transaction

View File

@@ -72,7 +72,13 @@ static const struct {
// Performance Settings
{"default_limit", "500"},
{"max_limit", "5000"}
{"max_limit", "5000"},
// Proxy Settings
// Trust proxy headers (X-Forwarded-For, X-Real-IP) for accurate client IP detection
// Safe for informational/debugging use. Only becomes a security concern if you implement
// IP-based rate limiting or access control (which would require firewall protection anyway)
{"trust_proxy_headers", "true"}
};
// Number of default configuration values

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,5 @@
// Note to assistants. dm_admin is only for functions relating to direct messaging
#ifndef DM_ADMIN_H
#define DM_ADMIN_H
@@ -24,4 +26,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

@@ -149,6 +149,11 @@ int mark_event_as_deleted(const char* event_id, const char* deletion_event_id, c
// Forward declaration for database functions
int store_event(cJSON* event);
// Forward declarations for monitoring system
void init_monitoring_system(void);
void cleanup_monitoring_system(void);
void monitoring_on_event_stored(void);
// Forward declarations for NIP-11 relay information handling
void init_relay_info();
void cleanup_relay_info();
@@ -729,8 +734,12 @@ int store_event(cJSON* event) {
free(tags_json);
return -1;
}
free(tags_json);
// Call monitoring hook after successful event storage
monitoring_on_event_stored();
return 0;
}
@@ -1979,6 +1988,10 @@ int main(int argc, char* argv[]) {
// Initialize NIP-40 expiration configuration
init_expiration_config();
// Initialize monitoring system
init_monitoring_system();
// Update subscription manager configuration
update_subscription_manager_config();
@@ -2010,6 +2023,9 @@ int main(int argc, char* argv[]) {
ginxsom_request_validator_cleanup();
cleanup_configuration_system();
// Cleanup monitoring system
cleanup_monitoring_system();
// Cleanup subscription manager mutexes
pthread_mutex_destroy(&g_subscription_manager.subscriptions_lock);
pthread_mutex_destroy(&g_subscription_manager.ip_tracking_lock);

View File

@@ -10,10 +10,10 @@
#define MAIN_H
// Version information (auto-updated by build system)
#define VERSION "v0.4.6"
#define VERSION "v0.7.31"
#define VERSION_MAJOR 0
#define VERSION_MINOR 4
#define VERSION_PATCH 6
#define VERSION_MINOR 7
#define VERSION_PATCH 31
// Relay metadata (authoritative source for NIP-11 information)
#define RELAY_NAME "C-Relay"

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

@@ -247,7 +247,57 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
// Get real client IP address
char client_ip[CLIENT_IP_MAX_LENGTH];
lws_get_peer_simple(wsi, client_ip, sizeof(client_ip));
memset(client_ip, 0, sizeof(client_ip));
// Check if we should trust proxy headers
int trust_proxy = get_config_bool("trust_proxy_headers", 0);
if (trust_proxy) {
// Try to get IP from X-Forwarded-For header first
char x_forwarded_for[CLIENT_IP_MAX_LENGTH];
int header_len = lws_hdr_copy(wsi, x_forwarded_for, sizeof(x_forwarded_for) - 1, WSI_TOKEN_X_FORWARDED_FOR);
if (header_len > 0) {
x_forwarded_for[header_len] = '\0';
// X-Forwarded-For can contain multiple IPs (client, proxy1, proxy2, ...)
// We want the first (leftmost) IP which is the original client
char* comma = strchr(x_forwarded_for, ',');
if (comma) {
*comma = '\0'; // Truncate at first comma
}
// Trim leading/trailing whitespace
char* ip_start = x_forwarded_for;
while (*ip_start == ' ' || *ip_start == '\t') ip_start++;
size_t ip_len = strlen(ip_start);
while (ip_len > 0 && (ip_start[ip_len-1] == ' ' || ip_start[ip_len-1] == '\t')) {
ip_start[--ip_len] = '\0';
}
if (ip_len > 0 && ip_len < CLIENT_IP_MAX_LENGTH) {
strncpy(client_ip, ip_start, CLIENT_IP_MAX_LENGTH - 1);
client_ip[CLIENT_IP_MAX_LENGTH - 1] = '\0';
DEBUG_TRACE("Using X-Forwarded-For IP: %s", client_ip);
}
}
// If X-Forwarded-For didn't work, try X-Real-IP
if (client_ip[0] == '\0') {
char x_real_ip[CLIENT_IP_MAX_LENGTH];
header_len = lws_hdr_copy(wsi, x_real_ip, sizeof(x_real_ip) - 1, WSI_TOKEN_HTTP_X_REAL_IP);
if (header_len > 0) {
x_real_ip[header_len] = '\0';
strncpy(client_ip, x_real_ip, CLIENT_IP_MAX_LENGTH - 1);
client_ip[CLIENT_IP_MAX_LENGTH - 1] = '\0';
DEBUG_TRACE("Using X-Real-IP: %s", client_ip);
}
}
}
// Fall back to direct connection IP if proxy headers not available or not trusted
if (client_ip[0] == '\0') {
lws_get_peer_simple(wsi, client_ip, sizeof(client_ip));
DEBUG_TRACE("Using direct connection IP: %s", client_ip);
}
// Ensure client_ip is null-terminated and copy safely
client_ip[CLIENT_IP_MAX_LENGTH - 1] = '\0';
@@ -256,6 +306,9 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
memcpy(pss->client_ip, client_ip, copy_len);
pss->client_ip[copy_len] = '\0';
// Record connection establishment time for duration tracking
pss->connection_established = time(NULL);
DEBUG_LOG("WebSocket connection established from %s", pss->client_ip);
// Initialize NIP-42 authentication state
@@ -707,38 +760,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 +891,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;
@@ -961,10 +966,38 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
case LWS_CALLBACK_CLOSED:
DEBUG_TRACE("WebSocket connection closed");
DEBUG_LOG("WebSocket connection closed from %s", pss ? pss->client_ip : "unknown");
// Clean up session subscriptions
// Enhanced closure logging with detailed diagnostics
if (pss) {
// Calculate connection duration
time_t now = time(NULL);
long duration = (pss->connection_established > 0) ?
(long)(now - pss->connection_established) : 0;
// Determine closure reason
const char* reason = "client_disconnect";
if (g_shutdown_flag || !g_server_running) {
reason = "server_shutdown";
}
// Format authentication status
char auth_status[80];
if (pss->authenticated && strlen(pss->authenticated_pubkey) > 0) {
// Show first 8 chars of pubkey for identification
snprintf(auth_status, sizeof(auth_status), "yes(%.8s...)", pss->authenticated_pubkey);
} else {
snprintf(auth_status, sizeof(auth_status), "no");
}
// Log comprehensive closure information
DEBUG_LOG("WebSocket CLOSED: ip=%s duration=%lds subscriptions=%d authenticated=%s reason=%s",
pss->client_ip,
duration,
pss->subscription_count,
auth_status,
reason);
// Clean up session subscriptions
pthread_mutex_lock(&pss->session_lock);
struct subscription* sub = pss->subscriptions;
@@ -979,6 +1012,8 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
pthread_mutex_unlock(&pss->session_lock);
pthread_mutex_destroy(&pss->session_lock);
} else {
DEBUG_LOG("WebSocket CLOSED: ip=unknown duration=0s subscriptions=0 authenticated=no reason=unknown");
}
DEBUG_TRACE("WebSocket connection cleanup complete");
break;

View File

@@ -38,6 +38,7 @@ struct per_session_data {
pthread_mutex_t session_lock; // Per-session thread safety
char client_ip[CLIENT_IP_MAX_LENGTH]; // Client IP for logging
int subscription_count; // Number of subscriptions for this session
time_t connection_established; // When WebSocket connection was established
// NIP-42 Authentication State
char authenticated_pubkey[65]; // Authenticated public key (64 hex + null)

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

1
text_graph Submodule

Submodule text_graph added at 0762bfbd1e