- 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
630 lines
19 KiB
Markdown
630 lines
19 KiB
Markdown
# 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 |