Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3da7b62a95 | ||
|
|
4f1fbee52c | ||
|
|
6592c37c6e |
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -1,6 +1,3 @@
|
||||
[submodule "blossom"]
|
||||
path = blossom
|
||||
url = ssh://git@git.laantungir.net:222/laantungir/blossom.git
|
||||
[submodule "nostr_core_lib"]
|
||||
path = nostr_core_lib
|
||||
url = ssh://git@git.laantungir.net:222/laantungir/nostr_core_lib.git
|
||||
|
||||
18
Makefile
18
Makefile
@@ -1,16 +1,20 @@
|
||||
# Ginxsom Blossom Server Makefile
|
||||
|
||||
CC = gcc
|
||||
CFLAGS = -Wall -Wextra -std=gnu99 -O2 -Inostr_core_lib/nostr_core -Inostr_core_lib/cjson $(shell pkg-config --cflags libwebsockets)
|
||||
LIBS = -lfcgi -lsqlite3 nostr_core_lib/libnostr_core_x64.a -lz -ldl -lpthread -lm -L/usr/local/lib -lsecp256k1 -lssl -lcrypto -lcurl $(shell pkg-config --libs libwebsockets)
|
||||
CFLAGS = -Wall -Wextra -std=gnu99 -O2 -Inostr_core_lib/nostr_core -Inostr_core_lib/cjson
|
||||
LIBS = -lfcgi -lsqlite3 nostr_core_lib/libnostr_core_x64.a -lz -ldl -lpthread -lm -L/usr/local/lib -lsecp256k1 -lssl -lcrypto -lcurl
|
||||
SRCDIR = src
|
||||
BUILDDIR = build
|
||||
TARGET = $(BUILDDIR)/ginxsom-fcgi
|
||||
|
||||
# Source files
|
||||
SOURCES = $(SRCDIR)/main.c $(SRCDIR)/admin_api.c $(SRCDIR)/admin_auth.c $(SRCDIR)/admin_event.c $(SRCDIR)/admin_websocket.c $(SRCDIR)/admin_handlers.c $(SRCDIR)/bud04.c $(SRCDIR)/bud06.c $(SRCDIR)/bud08.c $(SRCDIR)/bud09.c $(SRCDIR)/request_validator.c
|
||||
SOURCES = $(SRCDIR)/main.c $(SRCDIR)/admin_api.c $(SRCDIR)/admin_auth.c $(SRCDIR)/admin_event.c $(SRCDIR)/admin_handlers.c $(SRCDIR)/bud04.c $(SRCDIR)/bud06.c $(SRCDIR)/bud08.c $(SRCDIR)/bud09.c $(SRCDIR)/request_validator.c $(SRCDIR)/relay_client.c $(SRCDIR)/admin_commands.c
|
||||
OBJECTS = $(SOURCES:$(SRCDIR)/%.c=$(BUILDDIR)/%.o)
|
||||
|
||||
# Add core_relay_pool.c from nostr_core_lib
|
||||
POOL_SRC = nostr_core_lib/nostr_core/core_relay_pool.c
|
||||
POOL_OBJ = $(BUILDDIR)/core_relay_pool.o
|
||||
|
||||
# Default target
|
||||
all: $(TARGET)
|
||||
|
||||
@@ -22,9 +26,13 @@ $(BUILDDIR):
|
||||
$(BUILDDIR)/%.o: $(SRCDIR)/%.c | $(BUILDDIR)
|
||||
$(CC) $(CFLAGS) -c $< -o $@
|
||||
|
||||
# Compile core_relay_pool.o (needs src/ for request_validator.h)
|
||||
$(POOL_OBJ): $(POOL_SRC) | $(BUILDDIR)
|
||||
$(CC) $(CFLAGS) -I$(SRCDIR) -c $< -o $@
|
||||
|
||||
# Link final executable
|
||||
$(TARGET): $(OBJECTS)
|
||||
$(CC) $(OBJECTS) $(LIBS) -o $@
|
||||
$(TARGET): $(OBJECTS) $(POOL_OBJ)
|
||||
$(CC) $(OBJECTS) $(POOL_OBJ) $(LIBS) -o $@
|
||||
|
||||
# Clean build files
|
||||
clean:
|
||||
|
||||
126
README.md
126
README.md
@@ -369,6 +369,132 @@ Error responses include specific error codes:
|
||||
- `no_blob_hashes`: Missing valid SHA-256 hashes
|
||||
- `unsupported_media_type`: Non-JSON Content-Type
|
||||
|
||||
## Administrator API
|
||||
|
||||
Ginxsom uses an **event-based administration system** where all configuration and management commands are sent as signed Nostr events using the admin private key. All admin commands use **NIP-44 encrypted command arrays** for security.
|
||||
|
||||
### Authentication
|
||||
|
||||
All admin commands require signing with the admin private key configured in the server. The admin public key is stored in the database and checked against incoming Kind 23458 events.
|
||||
|
||||
### Event Structure
|
||||
|
||||
**Admin Command Event (Kind 23458):**
|
||||
```json
|
||||
{
|
||||
"id": "event_id",
|
||||
"pubkey": "admin_public_key",
|
||||
"created_at": 1234587890,
|
||||
"kind": 23458,
|
||||
"content": "NIP44_ENCRYPTED_COMMAND_ARRAY",
|
||||
"tags": [
|
||||
["p", "blossom_server_pubkey"]
|
||||
],
|
||||
"sig": "event_signature"
|
||||
}
|
||||
```
|
||||
|
||||
The `content` field contains a NIP-44 encrypted JSON array representing the command.
|
||||
|
||||
**Admin Response Event (Kind 23459):**
|
||||
```json
|
||||
{
|
||||
"id": "response_event_id",
|
||||
"pubkey": "blossom_server_pubkey",
|
||||
"created_at": 1234587890,
|
||||
"kind": 23459,
|
||||
"content": "NIP44_ENCRYPTED_RESPONSE_OBJECT",
|
||||
"tags": [
|
||||
["p", "admin_public_key"],
|
||||
["e", "request_event_id"]
|
||||
],
|
||||
"sig": "response_event_signature"
|
||||
}
|
||||
```
|
||||
|
||||
The `content` field contains a NIP-44 encrypted JSON response object.
|
||||
|
||||
### Admin Commands
|
||||
|
||||
All commands are sent as NIP-44 encrypted JSON arrays in the event content:
|
||||
|
||||
| Command Type | Command Format | Description |
|
||||
|--------------|----------------|-------------|
|
||||
| **Configuration Management** |
|
||||
| `config_query` | `["config_query", "all"]` | Query all configuration parameters |
|
||||
| `config_update` | `["config_update", [{"key": "max_file_size", "value": "209715200", ...}]]` | Update configuration parameters |
|
||||
| **Statistics & Monitoring** |
|
||||
| `stats_query` | `["stats_query"]` | Get comprehensive database and storage statistics |
|
||||
| `system_status` | `["system_command", "system_status"]` | Get system status and health metrics |
|
||||
| **Blossom Operations** |
|
||||
| `blob_list` | `["blob_list", "all"]` or `["blob_list", "pubkey", "abc123..."]` | List blobs with filtering |
|
||||
| `storage_stats` | `["storage_stats"]` | Get detailed storage statistics |
|
||||
| `mirror_status` | `["mirror_status"]` | Get status of mirroring operations |
|
||||
| `report_query` | `["report_query", "all"]` | Query content reports (BUD-09) |
|
||||
| **Database Queries** |
|
||||
| `sql_query` | `["sql_query", "SELECT * FROM blobs LIMIT 10"]` | Execute read-only SQL query |
|
||||
|
||||
### Configuration Categories
|
||||
|
||||
**Blossom Settings:**
|
||||
- `max_file_size`: Maximum upload size in bytes
|
||||
- `storage_path`: Blob storage directory path
|
||||
- `cdn_origin`: CDN URL for blob descriptors
|
||||
- `enable_nip94`: Include NIP-94 tags in responses
|
||||
|
||||
**Relay Client Settings:**
|
||||
- `enable_relay_connect`: Enable relay client functionality
|
||||
- `kind_0_content`: Profile metadata JSON
|
||||
- `kind_10002_tags`: Relay list JSON array
|
||||
|
||||
**Authentication Settings:**
|
||||
- `auth_enabled`: Enable auth rules system
|
||||
- `require_auth_upload`: Require authentication for uploads
|
||||
- `require_auth_delete`: Require authentication for deletes
|
||||
|
||||
**Limits:**
|
||||
- `max_blobs_per_user`: Per-user blob limit
|
||||
- `rate_limit_uploads`: Uploads per minute
|
||||
- `max_total_storage`: Total storage limit in bytes
|
||||
|
||||
### Response Format
|
||||
|
||||
All admin commands return signed EVENT responses via the relay connection. Responses use NIP-44 encrypted JSON content with structured data.
|
||||
|
||||
**Success Response Example:**
|
||||
```json
|
||||
{
|
||||
"query_type": "stats_query",
|
||||
"timestamp": 1234587890,
|
||||
"database_size_bytes": 1048576,
|
||||
"storage_size_bytes": 10737418240,
|
||||
"total_blobs": 1543,
|
||||
"blob_types": [
|
||||
{"type": "image/jpeg", "count": 856, "size_bytes": 5368709120}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response Example:**
|
||||
```json
|
||||
{
|
||||
"query_type": "config_update",
|
||||
"status": "error",
|
||||
"error": "invalid configuration value",
|
||||
"timestamp": 1234587890
|
||||
}
|
||||
```
|
||||
|
||||
### Security Features
|
||||
|
||||
- **Cryptographic Authentication**: Only admin pubkey can send commands
|
||||
- **NIP-44 Encryption**: All commands and responses are encrypted
|
||||
- **Command Logging**: All admin actions logged to database
|
||||
- **SQL Safety**: Only SELECT statements allowed with timeout and row limits
|
||||
- **Rate Limiting**: Prevents admin command flooding
|
||||
|
||||
For detailed command specifications and examples, see [`docs/ADMIN_COMMANDS_PLAN.md`](docs/ADMIN_COMMANDS_PLAN.md).
|
||||
|
||||
## File Storage
|
||||
|
||||
### Current (Flat) Structure
|
||||
|
||||
@@ -41,7 +41,10 @@ INSERT OR IGNORE INTO config (key, value, description) VALUES
|
||||
('admin_enabled', 'true', 'Whether admin API is enabled'),
|
||||
('nip42_require_auth', 'false', 'Enable NIP-42 challenge/response authentication'),
|
||||
('nip42_challenge_timeout', '600', 'NIP-42 challenge timeout in seconds'),
|
||||
('nip42_time_tolerance', '300', 'NIP-42 timestamp tolerance in seconds');
|
||||
('nip42_time_tolerance', '300', 'NIP-42 timestamp tolerance in seconds'),
|
||||
('enable_relay_connect', 'true', 'Enable Nostr relay client connections'),
|
||||
('kind_0_content', '{"name":"Ginxsom Blossom Server","about":"A Blossom media server for storing and serving files on Nostr","picture":"","nip05":""}', 'Kind 0 profile metadata content (JSON)'),
|
||||
('kind_10002_tags', '["wss://relay.laantungir.net"]', 'Kind 10002 relay list - JSON array of relay URLs');
|
||||
|
||||
-- Authentication rules table for whitelist/blacklist functionality
|
||||
CREATE TABLE IF NOT EXISTS auth_rules (
|
||||
|
||||
BIN
build/admin_commands.o
Normal file
BIN
build/admin_commands.o
Normal file
Binary file not shown.
Binary file not shown.
BIN
build/core_relay_pool.o
Normal file
BIN
build/core_relay_pool.o
Normal file
Binary file not shown.
Binary file not shown.
BIN
build/main.o
BIN
build/main.o
Binary file not shown.
BIN
build/relay_client.o
Normal file
BIN
build/relay_client.o
Normal file
Binary file not shown.
Binary file not shown.
@@ -2,7 +2,8 @@
|
||||
# Comprehensive Blossom Protocol Implementation
|
||||
|
||||
# Main context - specify error log here to override system default
|
||||
error_log logs/nginx/error.log info;
|
||||
# Set to warn level to capture FastCGI stderr messages
|
||||
error_log logs/nginx/error.log warn;
|
||||
pid logs/nginx/nginx.pid;
|
||||
|
||||
events {
|
||||
@@ -219,25 +220,6 @@ http {
|
||||
fastcgi_param HTTP_AUTHORIZATION $http_authorization;
|
||||
}
|
||||
|
||||
# WebSocket Admin endpoint (/admin) - Nostr Kind 23456/23457 events
|
||||
location /admin {
|
||||
proxy_pass http://127.0.0.1:9442;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket timeouts
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
|
||||
# Disable buffering for WebSocket
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# Admin API endpoints (/api/*)
|
||||
location /api/ {
|
||||
if ($request_method !~ ^(GET|PUT|POST)$) {
|
||||
@@ -589,25 +571,6 @@ http {
|
||||
fastcgi_param HTTP_AUTHORIZATION $http_authorization;
|
||||
}
|
||||
|
||||
# WebSocket Admin endpoint (/admin) - Nostr Kind 23456/23457 events
|
||||
location /admin {
|
||||
proxy_pass http://127.0.0.1:9442;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket timeouts
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
|
||||
# Disable buffering for WebSocket
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# Admin API endpoints (/api/*)
|
||||
location /api/ {
|
||||
if ($request_method !~ ^(GET|PUT|POST)$) {
|
||||
|
||||
Binary file not shown.
535
docs/ADMIN_COMMANDS_PLAN.md
Normal file
535
docs/ADMIN_COMMANDS_PLAN.md
Normal file
@@ -0,0 +1,535 @@
|
||||
# Ginxsom Admin Commands Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the implementation plan for Ginxsom's admin command system, adapted from c-relay's event-based administration system. Commands are sent as NIP-44 encrypted Kind 23456 events and responses are returned as Kind 23457 events.
|
||||
|
||||
## Command Analysis: c-relay vs Ginxsom
|
||||
|
||||
### Commands to Implement (Blossom-Relevant)
|
||||
|
||||
| c-relay Command | Ginxsom Equivalent | Rationale |
|
||||
|-----------------|-------------------|-----------|
|
||||
| `config_query` | `config_query` | Query Blossom server configuration |
|
||||
| `config_update` | `config_update` | Update server settings dynamically |
|
||||
| `stats_query` | `stats_query` | Database statistics (blobs, storage, etc.) |
|
||||
| `system_status` | `system_status` | Server health and status |
|
||||
| `sql_query` | `sql_query` | Direct database queries for debugging |
|
||||
| N/A | `blob_list` | List blobs by pubkey or criteria |
|
||||
| N/A | `storage_stats` | Storage usage and capacity info |
|
||||
| N/A | `mirror_status` | Status of mirroring operations |
|
||||
| N/A | `report_query` | Query content reports (BUD-09) |
|
||||
|
||||
### Commands to Exclude (Not Blossom-Relevant)
|
||||
|
||||
| c-relay Command | Reason for Exclusion |
|
||||
|-----------------|---------------------|
|
||||
| `auth_add_blacklist` | Blossom uses different auth model (per-blob, not per-pubkey) |
|
||||
| `auth_add_whitelist` | Same as above |
|
||||
| `auth_delete_rule` | Same as above |
|
||||
| `auth_query_all` | Same as above |
|
||||
| `system_clear_auth` | Same as above |
|
||||
|
||||
**Note**: Blossom's authentication is event-based per operation (upload/delete), not relay-level whitelist/blacklist. Auth rules in Ginxsom are configured via the `auth_rules` table but managed differently than c-relay.
|
||||
|
||||
## Event Structure
|
||||
|
||||
### Admin Command Event (Kind 23456)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "event_id",
|
||||
"pubkey": "admin_public_key",
|
||||
"created_at": 1234567890,
|
||||
"kind": 23456,
|
||||
"content": "NIP44_ENCRYPTED_COMMAND_ARRAY",
|
||||
"tags": [
|
||||
["p", "blossom_server_pubkey"]
|
||||
],
|
||||
"sig": "event_signature"
|
||||
}
|
||||
```
|
||||
|
||||
### Admin Response Event (Kind 23457)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "response_event_id",
|
||||
"pubkey": "blossom_server_pubkey",
|
||||
"created_at": 1234567890,
|
||||
"kind": 23457,
|
||||
"content": "NIP44_ENCRYPTED_RESPONSE_OBJECT",
|
||||
"tags": [
|
||||
["p", "admin_public_key"],
|
||||
["e", "request_event_id"]
|
||||
],
|
||||
"sig": "response_event_signature"
|
||||
}
|
||||
```
|
||||
|
||||
## Command Specifications
|
||||
|
||||
### 1. Configuration Management
|
||||
|
||||
#### `config_query`
|
||||
|
||||
Query server configuration parameters.
|
||||
|
||||
**Command Format:**
|
||||
```json
|
||||
["config_query", "all"]
|
||||
["config_query", "category", "blossom"]
|
||||
["config_query", "key", "max_file_size"]
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"query_type": "config_all",
|
||||
"total_results": 15,
|
||||
"timestamp": 1234567890,
|
||||
"data": [
|
||||
{
|
||||
"key": "max_file_size",
|
||||
"value": "104857600",
|
||||
"data_type": "integer",
|
||||
"category": "blossom",
|
||||
"description": "Maximum file size in bytes"
|
||||
},
|
||||
{
|
||||
"key": "enable_relay_connect",
|
||||
"value": "true",
|
||||
"data_type": "boolean",
|
||||
"category": "relay",
|
||||
"description": "Enable relay client functionality"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration Categories:**
|
||||
- `blossom`: Blossom protocol settings (max_file_size, storage_path, etc.)
|
||||
- `relay`: Relay client settings (enable_relay_connect, kind_0_content, etc.)
|
||||
- `auth`: Authentication settings (auth_enabled, nip42_required, etc.)
|
||||
- `limits`: Rate limits and quotas
|
||||
- `system`: System-level settings
|
||||
|
||||
#### `config_update`
|
||||
|
||||
Update configuration parameters dynamically.
|
||||
|
||||
**Command Format:**
|
||||
```json
|
||||
["config_update", [
|
||||
{
|
||||
"key": "max_file_size",
|
||||
"value": "209715200",
|
||||
"data_type": "integer",
|
||||
"category": "blossom"
|
||||
},
|
||||
{
|
||||
"key": "enable_relay_connect",
|
||||
"value": "true",
|
||||
"data_type": "boolean",
|
||||
"category": "relay"
|
||||
}
|
||||
]]
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"query_type": "config_update",
|
||||
"status": "success",
|
||||
"total_results": 2,
|
||||
"timestamp": 1234567890,
|
||||
"data": [
|
||||
{
|
||||
"key": "max_file_size",
|
||||
"value": "209715200",
|
||||
"status": "updated",
|
||||
"restart_required": false
|
||||
},
|
||||
{
|
||||
"key": "enable_relay_connect",
|
||||
"value": "true",
|
||||
"status": "updated",
|
||||
"restart_required": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Statistics and Monitoring
|
||||
|
||||
#### `stats_query`
|
||||
|
||||
Get comprehensive database and storage statistics.
|
||||
|
||||
**Command Format:**
|
||||
```json
|
||||
["stats_query"]
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"query_type": "stats_query",
|
||||
"timestamp": 1234567890,
|
||||
"database_size_bytes": 1048576,
|
||||
"storage_size_bytes": 10737418240,
|
||||
"total_blobs": 1543,
|
||||
"unique_uploaders": 234,
|
||||
"blob_types": [
|
||||
{"type": "image/jpeg", "count": 856, "size_bytes": 5368709120, "percentage": 55.4},
|
||||
{"type": "image/png", "count": 432, "size_bytes": 3221225472, "percentage": 28.0},
|
||||
{"type": "video/mp4", "count": 123, "size_bytes": 2147483648, "percentage": 8.0}
|
||||
],
|
||||
"time_stats": {
|
||||
"total": 1543,
|
||||
"last_24h": 45,
|
||||
"last_7d": 234,
|
||||
"last_30d": 876
|
||||
},
|
||||
"top_uploaders": [
|
||||
{"pubkey": "abc123...", "blob_count": 234, "total_bytes": 1073741824, "percentage": 15.2},
|
||||
{"pubkey": "def456...", "blob_count": 187, "total_bytes": 858993459, "percentage": 12.1}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `system_status`
|
||||
|
||||
Get current system status and health metrics.
|
||||
|
||||
**Command Format:**
|
||||
```json
|
||||
["system_command", "system_status"]
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"query_type": "system_status",
|
||||
"timestamp": 1234567890,
|
||||
"uptime_seconds": 86400,
|
||||
"version": "0.1.0",
|
||||
"relay_client": {
|
||||
"enabled": true,
|
||||
"connected_relays": 1,
|
||||
"relay_status": [
|
||||
{
|
||||
"url": "wss://relay.laantungir.net",
|
||||
"state": "connected",
|
||||
"events_received": 12,
|
||||
"events_published": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
"storage": {
|
||||
"path": "/home/teknari/lt_gitea/ginxsom/blobs",
|
||||
"total_bytes": 10737418240,
|
||||
"available_bytes": 53687091200,
|
||||
"usage_percentage": 16.7
|
||||
},
|
||||
"database": {
|
||||
"path": "db/52e366edfa4e9cc6a6d4653828e51ccf828a2f5a05227d7a768f33b5a198681a.db",
|
||||
"size_bytes": 1048576,
|
||||
"total_blobs": 1543
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Blossom-Specific Commands
|
||||
|
||||
#### `blob_list`
|
||||
|
||||
List blobs with filtering options.
|
||||
|
||||
**Command Format:**
|
||||
```json
|
||||
["blob_list", "all"]
|
||||
["blob_list", "pubkey", "abc123..."]
|
||||
["blob_list", "type", "image/jpeg"]
|
||||
["blob_list", "recent", 50]
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"query_type": "blob_list",
|
||||
"total_results": 50,
|
||||
"timestamp": 1234567890,
|
||||
"data": [
|
||||
{
|
||||
"sha256": "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553",
|
||||
"size": 184292,
|
||||
"type": "application/pdf",
|
||||
"uploaded_at": 1725105921,
|
||||
"uploader_pubkey": "abc123...",
|
||||
"url": "https://cdn.example.com/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `storage_stats`
|
||||
|
||||
Get detailed storage statistics.
|
||||
|
||||
**Command Format:**
|
||||
```json
|
||||
["storage_stats"]
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"query_type": "storage_stats",
|
||||
"timestamp": 1234567890,
|
||||
"storage_path": "/home/teknari/lt_gitea/ginxsom/blobs",
|
||||
"total_bytes": 10737418240,
|
||||
"available_bytes": 53687091200,
|
||||
"used_bytes": 10737418240,
|
||||
"usage_percentage": 16.7,
|
||||
"blob_count": 1543,
|
||||
"average_blob_size": 6958592,
|
||||
"largest_blob": {
|
||||
"sha256": "abc123...",
|
||||
"size": 104857600,
|
||||
"type": "video/mp4"
|
||||
},
|
||||
"by_type": [
|
||||
{"type": "image/jpeg", "count": 856, "total_bytes": 5368709120},
|
||||
{"type": "image/png", "count": 432, "total_bytes": 3221225472}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `mirror_status`
|
||||
|
||||
Get status of blob mirroring operations (BUD-04).
|
||||
|
||||
**Command Format:**
|
||||
```json
|
||||
["mirror_status"]
|
||||
["mirror_status", "sha256", "abc123..."]
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"query_type": "mirror_status",
|
||||
"timestamp": 1234567890,
|
||||
"total_mirrors": 23,
|
||||
"data": [
|
||||
{
|
||||
"sha256": "abc123...",
|
||||
"source_url": "https://cdn.example.com/abc123.jpg",
|
||||
"status": "completed",
|
||||
"mirrored_at": 1725105921,
|
||||
"size": 1048576
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `report_query`
|
||||
|
||||
Query content reports (BUD-09).
|
||||
|
||||
**Command Format:**
|
||||
```json
|
||||
["report_query", "all"]
|
||||
["report_query", "blob", "abc123..."]
|
||||
["report_query", "type", "nudity"]
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"query_type": "report_query",
|
||||
"total_results": 12,
|
||||
"timestamp": 1234567890,
|
||||
"data": [
|
||||
{
|
||||
"report_id": 1,
|
||||
"blob_sha256": "abc123...",
|
||||
"report_type": "nudity",
|
||||
"reporter_pubkey": "def456...",
|
||||
"content": "Inappropriate content",
|
||||
"reported_at": 1725105921
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Database Queries
|
||||
|
||||
#### `sql_query`
|
||||
|
||||
Execute read-only SQL queries for debugging.
|
||||
|
||||
**Command Format:**
|
||||
```json
|
||||
["sql_query", "SELECT * FROM blobs LIMIT 10"]
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"query_type": "sql_query",
|
||||
"request_id": "request_event_id",
|
||||
"timestamp": 1234567890,
|
||||
"query": "SELECT * FROM blobs LIMIT 10",
|
||||
"execution_time_ms": 12,
|
||||
"row_count": 10,
|
||||
"columns": ["sha256", "size", "type", "uploaded_at", "uploader_pubkey"],
|
||||
"rows": [
|
||||
["b1674191...", 184292, "application/pdf", 1725105921, "abc123..."]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Security:**
|
||||
- Only SELECT statements allowed
|
||||
- Query timeout: 5 seconds
|
||||
- Result row limit: 1000 rows
|
||||
- All queries logged
|
||||
|
||||
## Implementation Architecture
|
||||
|
||||
### 1. Command Processing Flow
|
||||
|
||||
```
|
||||
1. Relay client receives Kind 23456 event
|
||||
2. Verify sender is admin_pubkey
|
||||
3. Decrypt content using NIP-44
|
||||
4. Parse command array
|
||||
5. Validate command structure
|
||||
6. Execute command handler
|
||||
7. Generate response object
|
||||
8. Encrypt response using NIP-44
|
||||
9. Create Kind 23457 event
|
||||
10. Publish to relays
|
||||
```
|
||||
|
||||
### 2. Code Structure
|
||||
|
||||
**New Files:**
|
||||
- `src/admin_commands.c` - Command handlers
|
||||
- `src/admin_commands.h` - Command interface
|
||||
- `src/nip44.c` - NIP-44 encryption wrapper (uses nostr_core_lib)
|
||||
- `src/nip44.h` - NIP-44 interface
|
||||
|
||||
**Modified Files:**
|
||||
- `src/relay_client.c` - Add command processing to `on_admin_command_event()`
|
||||
- `src/main.c` - Initialize admin command system
|
||||
|
||||
### 3. Database Schema Additions
|
||||
|
||||
```sql
|
||||
-- Admin command log
|
||||
CREATE TABLE IF NOT EXISTS admin_commands (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_id TEXT NOT NULL,
|
||||
command_type TEXT NOT NULL,
|
||||
admin_pubkey TEXT NOT NULL,
|
||||
executed_at INTEGER NOT NULL,
|
||||
execution_time_ms INTEGER,
|
||||
status TEXT NOT NULL,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
-- Create index for command history queries
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_commands_executed
|
||||
ON admin_commands(executed_at DESC);
|
||||
```
|
||||
|
||||
### 4. Configuration Keys
|
||||
|
||||
**Blossom Category:**
|
||||
- `max_file_size` - Maximum upload size in bytes
|
||||
- `storage_path` - Blob storage directory
|
||||
- `cdn_origin` - CDN URL for blob descriptors
|
||||
- `enable_nip94` - Include NIP-94 tags in responses
|
||||
|
||||
**Relay Category:**
|
||||
- `enable_relay_connect` - Enable relay client
|
||||
- `kind_0_content` - Profile metadata JSON
|
||||
- `kind_10002_tags` - Relay list JSON array
|
||||
|
||||
**Auth Category:**
|
||||
- `auth_enabled` - Enable auth rules system
|
||||
- `require_auth_upload` - Require auth for uploads
|
||||
- `require_auth_delete` - Require auth for deletes
|
||||
|
||||
**Limits Category:**
|
||||
- `max_blobs_per_user` - Per-user blob limit
|
||||
- `rate_limit_uploads` - Uploads per minute
|
||||
- `max_total_storage` - Total storage limit in bytes
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: NIP-44 Encryption Support
|
||||
- Integrate nostr_core_lib NIP-44 functions
|
||||
- Create encryption/decryption wrappers
|
||||
- Test with sample data
|
||||
|
||||
### Phase 2: Command Infrastructure
|
||||
- Create admin_commands.c/h
|
||||
- Implement command parser
|
||||
- Add command logging to database
|
||||
- Implement response builder
|
||||
|
||||
### Phase 3: Core Commands
|
||||
- Implement `config_query`
|
||||
- Implement `config_update`
|
||||
- Implement `stats_query`
|
||||
- Implement `system_status`
|
||||
|
||||
### Phase 4: Blossom Commands
|
||||
- Implement `blob_list`
|
||||
- Implement `storage_stats`
|
||||
- Implement `mirror_status`
|
||||
- Implement `report_query`
|
||||
|
||||
### Phase 5: Advanced Features
|
||||
- Implement `sql_query` with security
|
||||
- Add command history tracking
|
||||
- Implement rate limiting for admin commands
|
||||
|
||||
### Phase 6: Testing & Documentation
|
||||
- Create test suite for each command
|
||||
- Update README.md with admin API section
|
||||
- Create example scripts using nak tool
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Authentication**: Only admin_pubkey can send commands
|
||||
2. **Encryption**: All commands/responses use NIP-44
|
||||
3. **Logging**: All admin actions logged to database
|
||||
4. **Rate Limiting**: Prevent admin command flooding
|
||||
5. **SQL Safety**: Only SELECT allowed, with timeout and row limits
|
||||
6. **Input Validation**: Strict validation of all command parameters
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit Tests**: Test each command handler independently
|
||||
2. **Integration Tests**: Test full command flow with encryption
|
||||
3. **Security Tests**: Verify auth checks and SQL injection prevention
|
||||
4. **Performance Tests**: Ensure commands don't block relay operations
|
||||
5. **Manual Tests**: Use nak tool to send real encrypted commands
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
Add new section to README.md after "Content Reporting (BUD-09)":
|
||||
|
||||
```markdown
|
||||
## Administrator API
|
||||
|
||||
Ginxsom uses an event-based administration system where commands are sent as
|
||||
NIP-44 encrypted Kind 23456 events and responses are returned as Kind 23457
|
||||
events. This provides secure, cryptographically authenticated remote management.
|
||||
|
||||
[Full admin API documentation here]
|
||||
1640
docs/WEBSOCKET_IMPLEMENTATION.md
Normal file
1640
docs/WEBSOCKET_IMPLEMENTATION.md
Normal file
File diff suppressed because it is too large
Load Diff
Submodule nostr_core_lib deleted from 7d7c3eafe8
@@ -6,7 +6,7 @@
|
||||
# Configuration
|
||||
|
||||
# Parse command line arguments
|
||||
TEST_MODE=0
|
||||
TEST_MODE=1 # Default to test mode
|
||||
FOLLOW_LOGS=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
@@ -15,14 +15,19 @@ while [[ $# -gt 0 ]]; do
|
||||
TEST_MODE=1
|
||||
shift
|
||||
;;
|
||||
-p|--production)
|
||||
TEST_MODE=0
|
||||
shift
|
||||
;;
|
||||
--follow)
|
||||
FOLLOW_LOGS=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Usage: $0 [-t|--test-keys] [--follow]"
|
||||
echo " -t, --test-keys Use test mode with keys from .test_keys"
|
||||
echo "Usage: $0 [-t|--test-keys] [-p|--production] [--follow]"
|
||||
echo " -t, --test-keys Use test mode with keys from .test_keys (DEFAULT)"
|
||||
echo " -p, --production Use production mode (generate new keys)"
|
||||
echo " --follow Follow logs in real-time"
|
||||
exit 1
|
||||
;;
|
||||
|
||||
743
src/admin_commands.c
Normal file
743
src/admin_commands.c
Normal file
@@ -0,0 +1,743 @@
|
||||
/*
|
||||
* Ginxsom Admin Commands Implementation
|
||||
*/
|
||||
|
||||
#include "admin_commands.h"
|
||||
#include "../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
#include <sqlite3.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
// Forward declare app_log
|
||||
typedef enum {
|
||||
LOG_DEBUG = 0,
|
||||
LOG_INFO = 1,
|
||||
LOG_WARN = 2,
|
||||
LOG_ERROR = 3
|
||||
} log_level_t;
|
||||
|
||||
void app_log(log_level_t level, const char* format, ...);
|
||||
|
||||
// Global state
|
||||
static struct {
|
||||
int initialized;
|
||||
char db_path[512];
|
||||
} g_admin_state = {0};
|
||||
|
||||
// Initialize admin command system
|
||||
int admin_commands_init(const char *db_path) {
|
||||
if (g_admin_state.initialized) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
strncpy(g_admin_state.db_path, db_path, sizeof(g_admin_state.db_path) - 1);
|
||||
g_admin_state.initialized = 1;
|
||||
|
||||
app_log(LOG_INFO, "Admin command system initialized");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// NIP-44 encryption helper
|
||||
int admin_encrypt_response(
|
||||
const unsigned char* server_privkey,
|
||||
const unsigned char* admin_pubkey,
|
||||
const char* plaintext_json,
|
||||
char* output,
|
||||
size_t output_size
|
||||
) {
|
||||
int result = nostr_nip44_encrypt(
|
||||
server_privkey,
|
||||
admin_pubkey,
|
||||
plaintext_json,
|
||||
output,
|
||||
output_size
|
||||
);
|
||||
|
||||
if (result != 0) {
|
||||
app_log(LOG_ERROR, "Failed to encrypt admin response: %d", result);
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// NIP-44 decryption helper
|
||||
int admin_decrypt_command(
|
||||
const unsigned char* server_privkey,
|
||||
const unsigned char* admin_pubkey,
|
||||
const char* encrypted_data,
|
||||
char* output,
|
||||
size_t output_size
|
||||
) {
|
||||
int result = nostr_nip44_decrypt(
|
||||
server_privkey,
|
||||
admin_pubkey,
|
||||
encrypted_data,
|
||||
output,
|
||||
output_size
|
||||
);
|
||||
|
||||
if (result != 0) {
|
||||
app_log(LOG_ERROR, "Failed to decrypt admin command: %d", result);
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Create error response
|
||||
static cJSON* create_error_response(const char* query_type, const char* error_msg) {
|
||||
cJSON* response = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(response, "query_type", query_type);
|
||||
cJSON_AddStringToObject(response, "status", "error");
|
||||
cJSON_AddStringToObject(response, "error", error_msg);
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
return response;
|
||||
}
|
||||
|
||||
// Process admin command array and generate response
|
||||
cJSON* admin_commands_process(cJSON* command_array, const char* request_event_id) {
|
||||
(void)request_event_id; // Reserved for future use (e.g., logging, tracking)
|
||||
|
||||
if (!cJSON_IsArray(command_array) || cJSON_GetArraySize(command_array) < 1) {
|
||||
return create_error_response("unknown", "Invalid command format");
|
||||
}
|
||||
|
||||
cJSON* cmd_type = cJSON_GetArrayItem(command_array, 0);
|
||||
if (!cJSON_IsString(cmd_type)) {
|
||||
return create_error_response("unknown", "Command type must be string");
|
||||
}
|
||||
|
||||
const char* command = cmd_type->valuestring;
|
||||
app_log(LOG_INFO, "Processing admin command: %s", command);
|
||||
|
||||
// Route to appropriate handler
|
||||
if (strcmp(command, "config_query") == 0) {
|
||||
return admin_cmd_config_query(command_array);
|
||||
}
|
||||
else if (strcmp(command, "config_update") == 0) {
|
||||
return admin_cmd_config_update(command_array);
|
||||
}
|
||||
else if (strcmp(command, "stats_query") == 0) {
|
||||
return admin_cmd_stats_query(command_array);
|
||||
}
|
||||
else if (strcmp(command, "system_command") == 0) {
|
||||
// Check second parameter for system_status
|
||||
if (cJSON_GetArraySize(command_array) >= 2) {
|
||||
cJSON* subcmd = cJSON_GetArrayItem(command_array, 1);
|
||||
if (cJSON_IsString(subcmd) && strcmp(subcmd->valuestring, "system_status") == 0) {
|
||||
return admin_cmd_system_status(command_array);
|
||||
}
|
||||
}
|
||||
return create_error_response("system_command", "Unknown system command");
|
||||
}
|
||||
else if (strcmp(command, "blob_list") == 0) {
|
||||
return admin_cmd_blob_list(command_array);
|
||||
}
|
||||
else if (strcmp(command, "storage_stats") == 0) {
|
||||
return admin_cmd_storage_stats(command_array);
|
||||
}
|
||||
else if (strcmp(command, "sql_query") == 0) {
|
||||
return admin_cmd_sql_query(command_array);
|
||||
}
|
||||
else {
|
||||
char error_msg[256];
|
||||
snprintf(error_msg, sizeof(error_msg), "Unknown command: %s", command);
|
||||
return create_error_response("unknown", error_msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COMMAND HANDLERS (Stub implementations - to be completed)
|
||||
// ============================================================================
|
||||
|
||||
cJSON* admin_cmd_config_query(cJSON* args) {
|
||||
cJSON* response = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(response, "query_type", "config_query");
|
||||
|
||||
// Open database
|
||||
sqlite3* db;
|
||||
int rc = sqlite3_open_v2(g_admin_state.db_path, &db, SQLITE_OPEN_READONLY, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
cJSON_AddStringToObject(response, "status", "error");
|
||||
cJSON_AddStringToObject(response, "error", "Failed to open database");
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
return response;
|
||||
}
|
||||
|
||||
// Check if specific keys were requested (args[1] should be array of keys or null for all)
|
||||
cJSON* keys_array = NULL;
|
||||
if (cJSON_GetArraySize(args) >= 2) {
|
||||
keys_array = cJSON_GetArrayItem(args, 1);
|
||||
if (!cJSON_IsArray(keys_array) && !cJSON_IsNull(keys_array)) {
|
||||
cJSON_AddStringToObject(response, "status", "error");
|
||||
cJSON_AddStringToObject(response, "error", "Keys parameter must be array or null");
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
sqlite3_close(db);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
sqlite3_stmt* stmt;
|
||||
const char* sql;
|
||||
|
||||
if (keys_array && cJSON_IsArray(keys_array) && cJSON_GetArraySize(keys_array) > 0) {
|
||||
// Query specific keys
|
||||
int key_count = cJSON_GetArraySize(keys_array);
|
||||
|
||||
// Build SQL with placeholders
|
||||
char sql_buffer[1024] = "SELECT key, value, description FROM config WHERE key IN (?";
|
||||
for (int i = 1; i < key_count && i < 50; i++) { // Limit to 50 keys
|
||||
strncat(sql_buffer, ",?", sizeof(sql_buffer) - strlen(sql_buffer) - 1);
|
||||
}
|
||||
strncat(sql_buffer, ")", sizeof(sql_buffer) - strlen(sql_buffer) - 1);
|
||||
|
||||
rc = sqlite3_prepare_v2(db, sql_buffer, -1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
cJSON_AddStringToObject(response, "status", "error");
|
||||
cJSON_AddStringToObject(response, "error", "Failed to prepare query");
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
sqlite3_close(db);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Bind keys
|
||||
for (int i = 0; i < key_count && i < 50; i++) {
|
||||
cJSON* key_item = cJSON_GetArrayItem(keys_array, i);
|
||||
if (cJSON_IsString(key_item)) {
|
||||
sqlite3_bind_text(stmt, i + 1, key_item->valuestring, -1, SQLITE_STATIC);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Query all config values
|
||||
sql = "SELECT key, value, description FROM config ORDER BY key";
|
||||
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
cJSON_AddStringToObject(response, "status", "error");
|
||||
cJSON_AddStringToObject(response, "error", "Failed to prepare query");
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
sqlite3_close(db);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute query and build result
|
||||
cJSON* config_obj = cJSON_CreateObject();
|
||||
int count = 0;
|
||||
|
||||
while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
|
||||
const char* key = (const char*)sqlite3_column_text(stmt, 0);
|
||||
const char* value = (const char*)sqlite3_column_text(stmt, 1);
|
||||
const char* description = (const char*)sqlite3_column_text(stmt, 2);
|
||||
|
||||
cJSON* entry = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(entry, "value", value ? value : "");
|
||||
if (description && strlen(description) > 0) {
|
||||
cJSON_AddStringToObject(entry, "description", description);
|
||||
}
|
||||
|
||||
cJSON_AddItemToObject(config_obj, key, entry);
|
||||
count++;
|
||||
}
|
||||
|
||||
sqlite3_finalize(stmt);
|
||||
sqlite3_close(db);
|
||||
|
||||
cJSON_AddStringToObject(response, "status", "success");
|
||||
cJSON_AddNumberToObject(response, "count", count);
|
||||
cJSON_AddItemToObject(response, "config", config_obj);
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
|
||||
app_log(LOG_INFO, "Config query returned %d entries", count);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
cJSON* admin_cmd_config_update(cJSON* args) {
|
||||
cJSON* response = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(response, "query_type", "config_update");
|
||||
|
||||
// Expected format: ["config_update", {"key1": "value1", "key2": "value2"}]
|
||||
if (cJSON_GetArraySize(args) < 2) {
|
||||
cJSON_AddStringToObject(response, "status", "error");
|
||||
cJSON_AddStringToObject(response, "error", "Missing config updates object");
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
return response;
|
||||
}
|
||||
|
||||
cJSON* updates = cJSON_GetArrayItem(args, 1);
|
||||
if (!cJSON_IsObject(updates)) {
|
||||
cJSON_AddStringToObject(response, "status", "error");
|
||||
cJSON_AddStringToObject(response, "error", "Updates must be an object");
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
return response;
|
||||
}
|
||||
|
||||
// Open database for writing
|
||||
sqlite3* db;
|
||||
int rc = sqlite3_open_v2(g_admin_state.db_path, &db, SQLITE_OPEN_READWRITE, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
cJSON_AddStringToObject(response, "status", "error");
|
||||
cJSON_AddStringToObject(response, "error", "Failed to open database");
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
return response;
|
||||
}
|
||||
|
||||
// Prepare update statement
|
||||
const char* sql = "UPDATE config SET value = ?, updated_at = strftime('%s', 'now') WHERE key = ?";
|
||||
sqlite3_stmt* stmt;
|
||||
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
cJSON_AddStringToObject(response, "status", "error");
|
||||
cJSON_AddStringToObject(response, "error", "Failed to prepare update statement");
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
sqlite3_close(db);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Process each update
|
||||
cJSON* updated_keys = cJSON_CreateArray();
|
||||
cJSON* failed_keys = cJSON_CreateArray();
|
||||
int success_count = 0;
|
||||
int fail_count = 0;
|
||||
|
||||
cJSON* item = NULL;
|
||||
cJSON_ArrayForEach(item, updates) {
|
||||
const char* key = item->string;
|
||||
const char* value = cJSON_GetStringValue(item);
|
||||
|
||||
if (!value) {
|
||||
cJSON_AddItemToArray(failed_keys, cJSON_CreateString(key));
|
||||
fail_count++;
|
||||
continue;
|
||||
}
|
||||
|
||||
sqlite3_reset(stmt);
|
||||
sqlite3_bind_text(stmt, 1, value, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, key, -1, SQLITE_TRANSIENT);
|
||||
|
||||
rc = sqlite3_step(stmt);
|
||||
if (rc == SQLITE_DONE && sqlite3_changes(db) > 0) {
|
||||
cJSON_AddItemToArray(updated_keys, cJSON_CreateString(key));
|
||||
success_count++;
|
||||
app_log(LOG_INFO, "Updated config key: %s", key);
|
||||
} else {
|
||||
cJSON_AddItemToArray(failed_keys, cJSON_CreateString(key));
|
||||
fail_count++;
|
||||
}
|
||||
}
|
||||
|
||||
sqlite3_finalize(stmt);
|
||||
sqlite3_close(db);
|
||||
|
||||
cJSON_AddStringToObject(response, "status", "success");
|
||||
cJSON_AddNumberToObject(response, "updated_count", success_count);
|
||||
cJSON_AddNumberToObject(response, "failed_count", fail_count);
|
||||
cJSON_AddItemToObject(response, "updated_keys", updated_keys);
|
||||
if (fail_count > 0) {
|
||||
cJSON_AddItemToObject(response, "failed_keys", failed_keys);
|
||||
} else {
|
||||
cJSON_Delete(failed_keys);
|
||||
}
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
cJSON* admin_cmd_stats_query(cJSON* args) {
|
||||
(void)args;
|
||||
|
||||
cJSON* response = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(response, "query_type", "stats_query");
|
||||
|
||||
// Open database
|
||||
sqlite3* db;
|
||||
int rc = sqlite3_open_v2(g_admin_state.db_path, &db, SQLITE_OPEN_READONLY, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
cJSON_AddStringToObject(response, "status", "error");
|
||||
cJSON_AddStringToObject(response, "error", "Failed to open database");
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
return response;
|
||||
}
|
||||
|
||||
// Query storage stats view
|
||||
const char* sql = "SELECT * FROM storage_stats";
|
||||
sqlite3_stmt* stmt;
|
||||
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
cJSON_AddStringToObject(response, "status", "error");
|
||||
cJSON_AddStringToObject(response, "error", "Failed to query stats");
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
sqlite3_close(db);
|
||||
return response;
|
||||
}
|
||||
|
||||
cJSON* stats = cJSON_CreateObject();
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
cJSON_AddNumberToObject(stats, "total_blobs", sqlite3_column_int64(stmt, 0));
|
||||
cJSON_AddNumberToObject(stats, "total_bytes", sqlite3_column_int64(stmt, 1));
|
||||
cJSON_AddNumberToObject(stats, "avg_blob_size", sqlite3_column_double(stmt, 2));
|
||||
cJSON_AddNumberToObject(stats, "first_upload", sqlite3_column_int64(stmt, 3));
|
||||
cJSON_AddNumberToObject(stats, "last_upload", sqlite3_column_int64(stmt, 4));
|
||||
cJSON_AddNumberToObject(stats, "unique_uploaders", sqlite3_column_int64(stmt, 5));
|
||||
}
|
||||
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
// Get auth rules count
|
||||
sql = "SELECT COUNT(*) FROM auth_rules WHERE enabled = 1";
|
||||
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
|
||||
if (rc == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
cJSON_AddNumberToObject(stats, "active_auth_rules", sqlite3_column_int(stmt, 0));
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
sqlite3_close(db);
|
||||
|
||||
cJSON_AddStringToObject(response, "status", "success");
|
||||
cJSON_AddItemToObject(response, "stats", stats);
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
cJSON* admin_cmd_system_status(cJSON* args) {
|
||||
(void)args;
|
||||
|
||||
cJSON* response = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(response, "query_type", "system_status");
|
||||
|
||||
cJSON* status = cJSON_CreateObject();
|
||||
|
||||
// Server uptime (would need to track start time - placeholder for now)
|
||||
cJSON_AddStringToObject(status, "server_status", "running");
|
||||
cJSON_AddNumberToObject(status, "current_time", (double)time(NULL));
|
||||
|
||||
// Database status
|
||||
sqlite3* db;
|
||||
int rc = sqlite3_open_v2(g_admin_state.db_path, &db, SQLITE_OPEN_READONLY, NULL);
|
||||
if (rc == SQLITE_OK) {
|
||||
cJSON_AddStringToObject(status, "database_status", "connected");
|
||||
|
||||
// Get database size
|
||||
sqlite3_stmt* stmt;
|
||||
const char* sql = "SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()";
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) == SQLITE_OK) {
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
cJSON_AddNumberToObject(status, "database_size_bytes", sqlite3_column_int64(stmt, 0));
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
sqlite3_close(db);
|
||||
} else {
|
||||
cJSON_AddStringToObject(status, "database_status", "error");
|
||||
}
|
||||
|
||||
// Memory info (basic - would need more system calls for detailed info)
|
||||
cJSON_AddStringToObject(status, "memory_status", "ok");
|
||||
|
||||
cJSON_AddStringToObject(response, "status", "success");
|
||||
cJSON_AddItemToObject(response, "system", status);
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
cJSON* admin_cmd_blob_list(cJSON* args) {
|
||||
cJSON* response = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(response, "query_type", "blob_list");
|
||||
|
||||
// Parse optional parameters: limit, offset, uploader_pubkey
|
||||
int limit = 100; // Default limit
|
||||
int offset = 0;
|
||||
const char* uploader_filter = NULL;
|
||||
|
||||
if (cJSON_GetArraySize(args) >= 2) {
|
||||
cJSON* params = cJSON_GetArrayItem(args, 1);
|
||||
if (cJSON_IsObject(params)) {
|
||||
cJSON* limit_item = cJSON_GetObjectItem(params, "limit");
|
||||
if (cJSON_IsNumber(limit_item)) {
|
||||
limit = limit_item->valueint;
|
||||
if (limit > 1000) limit = 1000; // Max 1000
|
||||
if (limit < 1) limit = 1;
|
||||
}
|
||||
|
||||
cJSON* offset_item = cJSON_GetObjectItem(params, "offset");
|
||||
if (cJSON_IsNumber(offset_item)) {
|
||||
offset = offset_item->valueint;
|
||||
if (offset < 0) offset = 0;
|
||||
}
|
||||
|
||||
cJSON* uploader_item = cJSON_GetObjectItem(params, "uploader");
|
||||
if (cJSON_IsString(uploader_item)) {
|
||||
uploader_filter = uploader_item->valuestring;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Open database
|
||||
sqlite3* db;
|
||||
int rc = sqlite3_open_v2(g_admin_state.db_path, &db, SQLITE_OPEN_READONLY, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
cJSON_AddStringToObject(response, "status", "error");
|
||||
cJSON_AddStringToObject(response, "error", "Failed to open database");
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
return response;
|
||||
}
|
||||
|
||||
// Build query
|
||||
char sql[512];
|
||||
if (uploader_filter) {
|
||||
snprintf(sql, sizeof(sql),
|
||||
"SELECT sha256, size, type, uploaded_at, uploader_pubkey, filename "
|
||||
"FROM blobs WHERE uploader_pubkey = ? "
|
||||
"ORDER BY uploaded_at DESC LIMIT ? OFFSET ?");
|
||||
} else {
|
||||
snprintf(sql, sizeof(sql),
|
||||
"SELECT sha256, size, type, uploaded_at, uploader_pubkey, filename "
|
||||
"FROM blobs ORDER BY uploaded_at DESC LIMIT ? OFFSET ?");
|
||||
}
|
||||
|
||||
sqlite3_stmt* stmt;
|
||||
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
cJSON_AddStringToObject(response, "status", "error");
|
||||
cJSON_AddStringToObject(response, "error", "Failed to prepare query");
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
sqlite3_close(db);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Bind parameters
|
||||
int param_idx = 1;
|
||||
if (uploader_filter) {
|
||||
sqlite3_bind_text(stmt, param_idx++, uploader_filter, -1, SQLITE_STATIC);
|
||||
}
|
||||
sqlite3_bind_int(stmt, param_idx++, limit);
|
||||
sqlite3_bind_int(stmt, param_idx++, offset);
|
||||
|
||||
// Execute and build results
|
||||
cJSON* blobs = cJSON_CreateArray();
|
||||
int count = 0;
|
||||
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
cJSON* blob = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(blob, "sha256", (const char*)sqlite3_column_text(stmt, 0));
|
||||
cJSON_AddNumberToObject(blob, "size", sqlite3_column_int64(stmt, 1));
|
||||
cJSON_AddStringToObject(blob, "type", (const char*)sqlite3_column_text(stmt, 2));
|
||||
cJSON_AddNumberToObject(blob, "uploaded_at", sqlite3_column_int64(stmt, 3));
|
||||
|
||||
const char* uploader = (const char*)sqlite3_column_text(stmt, 4);
|
||||
if (uploader) {
|
||||
cJSON_AddStringToObject(blob, "uploader_pubkey", uploader);
|
||||
}
|
||||
|
||||
const char* filename = (const char*)sqlite3_column_text(stmt, 5);
|
||||
if (filename) {
|
||||
cJSON_AddStringToObject(blob, "filename", filename);
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(blobs, blob);
|
||||
count++;
|
||||
}
|
||||
|
||||
sqlite3_finalize(stmt);
|
||||
sqlite3_close(db);
|
||||
|
||||
cJSON_AddStringToObject(response, "status", "success");
|
||||
cJSON_AddNumberToObject(response, "count", count);
|
||||
cJSON_AddNumberToObject(response, "limit", limit);
|
||||
cJSON_AddNumberToObject(response, "offset", offset);
|
||||
cJSON_AddItemToObject(response, "blobs", blobs);
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
cJSON* admin_cmd_storage_stats(cJSON* args) {
|
||||
(void)args;
|
||||
|
||||
cJSON* response = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(response, "query_type", "storage_stats");
|
||||
|
||||
// Open database
|
||||
sqlite3* db;
|
||||
int rc = sqlite3_open_v2(g_admin_state.db_path, &db, SQLITE_OPEN_READONLY, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
cJSON_AddStringToObject(response, "status", "error");
|
||||
cJSON_AddStringToObject(response, "error", "Failed to open database");
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
return response;
|
||||
}
|
||||
|
||||
cJSON* storage = cJSON_CreateObject();
|
||||
|
||||
// Get overall stats from view
|
||||
const char* sql = "SELECT * FROM storage_stats";
|
||||
sqlite3_stmt* stmt;
|
||||
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
|
||||
if (rc == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
cJSON_AddNumberToObject(storage, "total_blobs", sqlite3_column_int64(stmt, 0));
|
||||
cJSON_AddNumberToObject(storage, "total_bytes", sqlite3_column_int64(stmt, 1));
|
||||
cJSON_AddNumberToObject(storage, "avg_blob_size", sqlite3_column_double(stmt, 2));
|
||||
cJSON_AddNumberToObject(storage, "first_upload", sqlite3_column_int64(stmt, 3));
|
||||
cJSON_AddNumberToObject(storage, "last_upload", sqlite3_column_int64(stmt, 4));
|
||||
cJSON_AddNumberToObject(storage, "unique_uploaders", sqlite3_column_int64(stmt, 5));
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
// Get stats by MIME type
|
||||
sql = "SELECT type, COUNT(*) as count, SUM(size) as total_size "
|
||||
"FROM blobs GROUP BY type ORDER BY count DESC LIMIT 10";
|
||||
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
|
||||
if (rc == SQLITE_OK) {
|
||||
cJSON* by_type = cJSON_CreateArray();
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
cJSON* type_stat = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(type_stat, "mime_type", (const char*)sqlite3_column_text(stmt, 0));
|
||||
cJSON_AddNumberToObject(type_stat, "count", sqlite3_column_int64(stmt, 1));
|
||||
cJSON_AddNumberToObject(type_stat, "total_bytes", sqlite3_column_int64(stmt, 2));
|
||||
cJSON_AddItemToArray(by_type, type_stat);
|
||||
}
|
||||
cJSON_AddItemToObject(storage, "by_mime_type", by_type);
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
// Get top uploaders
|
||||
sql = "SELECT uploader_pubkey, COUNT(*) as count, SUM(size) as total_size "
|
||||
"FROM blobs WHERE uploader_pubkey IS NOT NULL "
|
||||
"GROUP BY uploader_pubkey ORDER BY count DESC LIMIT 10";
|
||||
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
|
||||
if (rc == SQLITE_OK) {
|
||||
cJSON* top_uploaders = cJSON_CreateArray();
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
cJSON* uploader_stat = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(uploader_stat, "pubkey", (const char*)sqlite3_column_text(stmt, 0));
|
||||
cJSON_AddNumberToObject(uploader_stat, "blob_count", sqlite3_column_int64(stmt, 1));
|
||||
cJSON_AddNumberToObject(uploader_stat, "total_bytes", sqlite3_column_int64(stmt, 2));
|
||||
cJSON_AddItemToArray(top_uploaders, uploader_stat);
|
||||
}
|
||||
cJSON_AddItemToObject(storage, "top_uploaders", top_uploaders);
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
sqlite3_close(db);
|
||||
|
||||
cJSON_AddStringToObject(response, "status", "success");
|
||||
cJSON_AddItemToObject(response, "storage", storage);
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
cJSON* admin_cmd_sql_query(cJSON* args) {
|
||||
cJSON* response = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(response, "query_type", "sql_query");
|
||||
|
||||
// Expected format: ["sql_query", "SELECT ..."]
|
||||
if (cJSON_GetArraySize(args) < 2) {
|
||||
cJSON_AddStringToObject(response, "status", "error");
|
||||
cJSON_AddStringToObject(response, "error", "Missing SQL query");
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
return response;
|
||||
}
|
||||
|
||||
cJSON* query_item = cJSON_GetArrayItem(args, 1);
|
||||
if (!cJSON_IsString(query_item)) {
|
||||
cJSON_AddStringToObject(response, "status", "error");
|
||||
cJSON_AddStringToObject(response, "error", "Query must be a string");
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
return response;
|
||||
}
|
||||
|
||||
const char* sql = query_item->valuestring;
|
||||
|
||||
// Security: Only allow SELECT queries
|
||||
const char* sql_upper = sql;
|
||||
while (*sql_upper == ' ' || *sql_upper == '\t' || *sql_upper == '\n') sql_upper++;
|
||||
if (strncasecmp(sql_upper, "SELECT", 6) != 0) {
|
||||
cJSON_AddStringToObject(response, "status", "error");
|
||||
cJSON_AddStringToObject(response, "error", "Only SELECT queries are allowed");
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
return response;
|
||||
}
|
||||
|
||||
// Open database (read-only for safety)
|
||||
sqlite3* db;
|
||||
int rc = sqlite3_open_v2(g_admin_state.db_path, &db, SQLITE_OPEN_READONLY, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
cJSON_AddStringToObject(response, "status", "error");
|
||||
cJSON_AddStringToObject(response, "error", "Failed to open database");
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
return response;
|
||||
}
|
||||
|
||||
// Prepare and execute query
|
||||
sqlite3_stmt* stmt;
|
||||
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
cJSON_AddStringToObject(response, "status", "error");
|
||||
char error_msg[256];
|
||||
snprintf(error_msg, sizeof(error_msg), "SQL error: %s", sqlite3_errmsg(db));
|
||||
cJSON_AddStringToObject(response, "error", error_msg);
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
sqlite3_close(db);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Get column names
|
||||
int col_count = sqlite3_column_count(stmt);
|
||||
cJSON* columns = cJSON_CreateArray();
|
||||
for (int i = 0; i < col_count; i++) {
|
||||
cJSON_AddItemToArray(columns, cJSON_CreateString(sqlite3_column_name(stmt, i)));
|
||||
}
|
||||
|
||||
// Execute and collect rows (limit to 1000 rows for safety)
|
||||
cJSON* rows = cJSON_CreateArray();
|
||||
int row_count = 0;
|
||||
const int MAX_ROWS = 1000;
|
||||
|
||||
while (row_count < MAX_ROWS && (rc = sqlite3_step(stmt)) == SQLITE_ROW) {
|
||||
cJSON* row = cJSON_CreateArray();
|
||||
for (int i = 0; i < col_count; i++) {
|
||||
int col_type = sqlite3_column_type(stmt, i);
|
||||
switch (col_type) {
|
||||
case SQLITE_INTEGER:
|
||||
cJSON_AddItemToArray(row, cJSON_CreateNumber(sqlite3_column_int64(stmt, i)));
|
||||
break;
|
||||
case SQLITE_FLOAT:
|
||||
cJSON_AddItemToArray(row, cJSON_CreateNumber(sqlite3_column_double(stmt, i)));
|
||||
break;
|
||||
case SQLITE_TEXT:
|
||||
cJSON_AddItemToArray(row, cJSON_CreateString((const char*)sqlite3_column_text(stmt, i)));
|
||||
break;
|
||||
case SQLITE_NULL:
|
||||
cJSON_AddItemToArray(row, cJSON_CreateNull());
|
||||
break;
|
||||
default:
|
||||
cJSON_AddItemToArray(row, cJSON_CreateString(""));
|
||||
}
|
||||
}
|
||||
cJSON_AddItemToArray(rows, row);
|
||||
row_count++;
|
||||
}
|
||||
|
||||
sqlite3_finalize(stmt);
|
||||
sqlite3_close(db);
|
||||
|
||||
cJSON_AddStringToObject(response, "status", "success");
|
||||
cJSON_AddItemToObject(response, "columns", columns);
|
||||
cJSON_AddItemToObject(response, "rows", rows);
|
||||
cJSON_AddNumberToObject(response, "row_count", row_count);
|
||||
if (row_count >= MAX_ROWS) {
|
||||
cJSON_AddBoolToObject(response, "truncated", 1);
|
||||
}
|
||||
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||
|
||||
app_log(LOG_INFO, "SQL query executed: %d rows returned", row_count);
|
||||
|
||||
return response;
|
||||
}
|
||||
56
src/admin_commands.h
Normal file
56
src/admin_commands.h
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Ginxsom Admin Commands Interface
|
||||
*
|
||||
* Handles encrypted admin commands sent via Kind 23456 events
|
||||
* and generates encrypted responses as Kind 23457 events.
|
||||
*/
|
||||
|
||||
#ifndef ADMIN_COMMANDS_H
|
||||
#define ADMIN_COMMANDS_H
|
||||
|
||||
#include <cjson/cJSON.h>
|
||||
|
||||
// Command handler result codes
|
||||
typedef enum {
|
||||
ADMIN_CMD_SUCCESS = 0,
|
||||
ADMIN_CMD_ERROR_PARSE = -1,
|
||||
ADMIN_CMD_ERROR_UNKNOWN = -2,
|
||||
ADMIN_CMD_ERROR_INVALID = -3,
|
||||
ADMIN_CMD_ERROR_DATABASE = -4,
|
||||
ADMIN_CMD_ERROR_PERMISSION = -5
|
||||
} admin_cmd_result_t;
|
||||
|
||||
// Initialize admin command system
|
||||
int admin_commands_init(const char *db_path);
|
||||
|
||||
// Process an admin command and generate response
|
||||
// Returns cJSON response object (caller must free with cJSON_Delete)
|
||||
cJSON* admin_commands_process(cJSON* command_array, const char* request_event_id);
|
||||
|
||||
// Individual command handlers
|
||||
cJSON* admin_cmd_config_query(cJSON* args);
|
||||
cJSON* admin_cmd_config_update(cJSON* args);
|
||||
cJSON* admin_cmd_stats_query(cJSON* args);
|
||||
cJSON* admin_cmd_system_status(cJSON* args);
|
||||
cJSON* admin_cmd_blob_list(cJSON* args);
|
||||
cJSON* admin_cmd_storage_stats(cJSON* args);
|
||||
cJSON* admin_cmd_sql_query(cJSON* args);
|
||||
|
||||
// NIP-44 encryption/decryption helpers
|
||||
int admin_encrypt_response(
|
||||
const unsigned char* server_privkey,
|
||||
const unsigned char* admin_pubkey,
|
||||
const char* plaintext_json,
|
||||
char* output,
|
||||
size_t output_size
|
||||
);
|
||||
|
||||
int admin_decrypt_command(
|
||||
const unsigned char* server_privkey,
|
||||
const unsigned char* admin_pubkey,
|
||||
const char* encrypted_data,
|
||||
char* output,
|
||||
size_t output_size
|
||||
);
|
||||
|
||||
#endif /* ADMIN_COMMANDS_H */
|
||||
@@ -1,541 +0,0 @@
|
||||
/*
|
||||
* Ginxsom Admin WebSocket Server
|
||||
* Handles WebSocket connections for Kind 23456/23457 admin commands
|
||||
* Based on c-relay's WebSocket implementation using libwebsockets
|
||||
*/
|
||||
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <cjson/cJSON.h>
|
||||
#include <sqlite3.h>
|
||||
#include <libwebsockets.h>
|
||||
#include "ginxsom.h"
|
||||
|
||||
// Forward declarations from admin_event.c
|
||||
extern char g_db_path[];
|
||||
extern int nostr_hex_to_bytes(const char* hex, unsigned char* bytes, size_t bytes_len);
|
||||
extern int nostr_nip44_decrypt(const unsigned char* recipient_private_key,
|
||||
const unsigned char* sender_public_key,
|
||||
const char* encrypted_data,
|
||||
char* output,
|
||||
size_t output_size);
|
||||
extern int nostr_nip44_encrypt(const unsigned char* sender_private_key,
|
||||
const unsigned char* recipient_public_key,
|
||||
const char* plaintext,
|
||||
char* output,
|
||||
size_t output_size);
|
||||
extern cJSON* nostr_create_and_sign_event(int kind, const char* content, cJSON* tags,
|
||||
const unsigned char* private_key, time_t created_at);
|
||||
|
||||
// Per-session data for each WebSocket connection
|
||||
struct per_session_data {
|
||||
char admin_pubkey[65];
|
||||
int authenticated;
|
||||
unsigned char pending_response[LWS_PRE + 131072];
|
||||
size_t pending_response_len;
|
||||
};
|
||||
|
||||
// Global WebSocket context
|
||||
static struct lws_context *ws_context = NULL;
|
||||
static volatile int force_exit = 0;
|
||||
|
||||
// Function prototypes
|
||||
static int get_server_privkey(unsigned char* privkey_bytes);
|
||||
static int get_server_pubkey(char* pubkey_hex, size_t size);
|
||||
static int handle_config_query_command(cJSON* response_data);
|
||||
static int process_admin_event(struct lws *wsi, struct per_session_data *pss, const char *json_str);
|
||||
|
||||
/**
|
||||
* WebSocket protocol callback
|
||||
*/
|
||||
static int callback_admin_protocol(struct lws *wsi, enum lws_callback_reasons reason,
|
||||
void *user, void *in, size_t len) {
|
||||
struct per_session_data *pss = (struct per_session_data *)user;
|
||||
|
||||
switch (reason) {
|
||||
case LWS_CALLBACK_ESTABLISHED:
|
||||
fprintf(stderr, "[WebSocket] New connection established\n");
|
||||
fflush(stderr);
|
||||
memset(pss, 0, sizeof(*pss));
|
||||
pss->authenticated = 0;
|
||||
break;
|
||||
|
||||
case LWS_CALLBACK_RECEIVE:
|
||||
fprintf(stderr, "[WebSocket] Received %zu bytes\n", len);
|
||||
fflush(stderr);
|
||||
|
||||
// Null-terminate the received data
|
||||
char *json_str = malloc(len + 1);
|
||||
if (!json_str) {
|
||||
fprintf(stderr, "[WebSocket] Memory allocation failed\n");
|
||||
fflush(stderr);
|
||||
return -1;
|
||||
}
|
||||
memcpy(json_str, in, len);
|
||||
json_str[len] = '\0';
|
||||
|
||||
// Process the admin event
|
||||
int result = process_admin_event(wsi, pss, json_str);
|
||||
free(json_str);
|
||||
|
||||
if (result == 0 && pss->pending_response_len > 0) {
|
||||
// Request callback to send response
|
||||
lws_callback_on_writable(wsi);
|
||||
}
|
||||
break;
|
||||
|
||||
case LWS_CALLBACK_SERVER_WRITEABLE:
|
||||
if (pss->pending_response_len > 0) {
|
||||
fprintf(stderr, "[WebSocket] Sending %zu bytes\n", pss->pending_response_len - LWS_PRE);
|
||||
fflush(stderr);
|
||||
|
||||
int written = lws_write(wsi,
|
||||
&pss->pending_response[LWS_PRE],
|
||||
pss->pending_response_len - LWS_PRE,
|
||||
LWS_WRITE_TEXT);
|
||||
|
||||
if (written < 0) {
|
||||
fprintf(stderr, "[WebSocket] Write failed\n");
|
||||
fflush(stderr);
|
||||
return -1;
|
||||
}
|
||||
|
||||
pss->pending_response_len = 0;
|
||||
}
|
||||
break;
|
||||
|
||||
case LWS_CALLBACK_CLOSED:
|
||||
fprintf(stderr, "[WebSocket] Connection closed\n");
|
||||
fflush(stderr);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket protocols
|
||||
*/
|
||||
static struct lws_protocols protocols[] = {
|
||||
{
|
||||
"nostr-admin",
|
||||
callback_admin_protocol,
|
||||
sizeof(struct per_session_data),
|
||||
131072, // rx buffer size
|
||||
0, NULL, 0
|
||||
},
|
||||
{ NULL, NULL, 0, 0, 0, NULL, 0 } // terminator
|
||||
};
|
||||
|
||||
/**
|
||||
* Process Kind 23456 admin event received via WebSocket
|
||||
*/
|
||||
static int process_admin_event(struct lws *wsi __attribute__((unused)), struct per_session_data *pss, const char *json_str) {
|
||||
// Parse event JSON
|
||||
cJSON *event = cJSON_Parse(json_str);
|
||||
if (!event) {
|
||||
fprintf(stderr, "[WebSocket] Invalid JSON\n");
|
||||
fflush(stderr);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Verify it's Kind 23456
|
||||
cJSON *kind_obj = cJSON_GetObjectItem(event, "kind");
|
||||
if (!kind_obj || !cJSON_IsNumber(kind_obj) ||
|
||||
(int)cJSON_GetNumberValue(kind_obj) != 23456) {
|
||||
fprintf(stderr, "[WebSocket] Not a Kind 23456 event\n");
|
||||
fflush(stderr);
|
||||
cJSON_Delete(event);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Get event ID for response correlation
|
||||
cJSON *id_obj = cJSON_GetObjectItem(event, "id");
|
||||
if (!id_obj || !cJSON_IsString(id_obj)) {
|
||||
fprintf(stderr, "[WebSocket] Event missing id\n");
|
||||
fflush(stderr);
|
||||
cJSON_Delete(event);
|
||||
return -1;
|
||||
}
|
||||
const char *request_id = cJSON_GetStringValue(id_obj);
|
||||
|
||||
// Get admin pubkey from event
|
||||
cJSON *pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
|
||||
if (!pubkey_obj || !cJSON_IsString(pubkey_obj)) {
|
||||
fprintf(stderr, "[WebSocket] Event missing pubkey\n");
|
||||
fflush(stderr);
|
||||
cJSON_Delete(event);
|
||||
return -1;
|
||||
}
|
||||
const char *admin_pubkey = cJSON_GetStringValue(pubkey_obj);
|
||||
|
||||
// Verify admin pubkey
|
||||
if (!verify_admin_pubkey(admin_pubkey)) {
|
||||
fprintf(stderr, "[WebSocket] Not authorized as admin: %s\n", admin_pubkey);
|
||||
fflush(stderr);
|
||||
cJSON_Delete(event);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Store admin pubkey in session
|
||||
strncpy(pss->admin_pubkey, admin_pubkey, sizeof(pss->admin_pubkey) - 1);
|
||||
pss->authenticated = 1;
|
||||
|
||||
// Get encrypted content
|
||||
cJSON *content_obj = cJSON_GetObjectItem(event, "content");
|
||||
if (!content_obj || !cJSON_IsString(content_obj)) {
|
||||
fprintf(stderr, "[WebSocket] Event missing content\n");
|
||||
fflush(stderr);
|
||||
cJSON_Delete(event);
|
||||
return -1;
|
||||
}
|
||||
const char *encrypted_content = cJSON_GetStringValue(content_obj);
|
||||
|
||||
// Get server private key for decryption
|
||||
unsigned char server_privkey[32];
|
||||
if (get_server_privkey(server_privkey) != 0) {
|
||||
fprintf(stderr, "[WebSocket] Failed to get server private key\n");
|
||||
fflush(stderr);
|
||||
cJSON_Delete(event);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Convert admin pubkey to bytes
|
||||
unsigned char admin_pubkey_bytes[32];
|
||||
if (nostr_hex_to_bytes(admin_pubkey, admin_pubkey_bytes, 32) != 0) {
|
||||
fprintf(stderr, "[WebSocket] Invalid admin pubkey format\n");
|
||||
fflush(stderr);
|
||||
cJSON_Delete(event);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Decrypt content using NIP-44
|
||||
char decrypted_content[8192];
|
||||
const char *content_to_parse = encrypted_content;
|
||||
|
||||
// Check if content is already plaintext JSON (starts with '[')
|
||||
if (encrypted_content[0] != '[') {
|
||||
int decrypt_result = nostr_nip44_decrypt(
|
||||
server_privkey,
|
||||
admin_pubkey_bytes,
|
||||
encrypted_content,
|
||||
decrypted_content,
|
||||
sizeof(decrypted_content)
|
||||
);
|
||||
|
||||
if (decrypt_result != 0) {
|
||||
fprintf(stderr, "[WebSocket] Failed to decrypt content\n");
|
||||
fflush(stderr);
|
||||
cJSON_Delete(event);
|
||||
return -1;
|
||||
}
|
||||
content_to_parse = decrypted_content;
|
||||
}
|
||||
|
||||
// Parse command array
|
||||
cJSON *command_array = cJSON_Parse(content_to_parse);
|
||||
if (!command_array || !cJSON_IsArray(command_array)) {
|
||||
fprintf(stderr, "[WebSocket] Decrypted content is not a valid command array\n");
|
||||
fflush(stderr);
|
||||
cJSON_Delete(event);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Get command type
|
||||
cJSON *command_type = cJSON_GetArrayItem(command_array, 0);
|
||||
if (!command_type || !cJSON_IsString(command_type)) {
|
||||
fprintf(stderr, "[WebSocket] Invalid command format\n");
|
||||
fflush(stderr);
|
||||
cJSON_Delete(command_array);
|
||||
cJSON_Delete(event);
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char *cmd = cJSON_GetStringValue(command_type);
|
||||
fprintf(stderr, "[WebSocket] Processing command: %s\n", cmd);
|
||||
fflush(stderr);
|
||||
|
||||
// Create response data object
|
||||
cJSON *response_data = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(response_data, "query_type", cmd);
|
||||
cJSON_AddNumberToObject(response_data, "timestamp", (double)time(NULL));
|
||||
|
||||
// Handle command
|
||||
int result = -1;
|
||||
if (strcmp(cmd, "config_query") == 0) {
|
||||
result = handle_config_query_command(response_data);
|
||||
} else {
|
||||
cJSON_AddStringToObject(response_data, "status", "error");
|
||||
cJSON_AddStringToObject(response_data, "error", "Unknown command");
|
||||
}
|
||||
|
||||
cJSON_Delete(command_array);
|
||||
cJSON_Delete(event);
|
||||
|
||||
if (result == 0) {
|
||||
// Get server keys
|
||||
char server_pubkey[65];
|
||||
if (get_server_pubkey(server_pubkey, sizeof(server_pubkey)) != 0) {
|
||||
fprintf(stderr, "[WebSocket] Failed to get server pubkey\n");
|
||||
fflush(stderr);
|
||||
cJSON_Delete(response_data);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Convert response data to JSON string
|
||||
char *response_json = cJSON_PrintUnformatted(response_data);
|
||||
cJSON_Delete(response_data);
|
||||
|
||||
if (!response_json) {
|
||||
fprintf(stderr, "[WebSocket] Failed to serialize response\n");
|
||||
fflush(stderr);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Encrypt response using NIP-44
|
||||
char encrypted_response[131072];
|
||||
int encrypt_result = nostr_nip44_encrypt(
|
||||
server_privkey,
|
||||
admin_pubkey_bytes,
|
||||
response_json,
|
||||
encrypted_response,
|
||||
sizeof(encrypted_response)
|
||||
);
|
||||
|
||||
free(response_json);
|
||||
|
||||
if (encrypt_result != 0) {
|
||||
fprintf(stderr, "[WebSocket] Failed to encrypt response\n");
|
||||
fflush(stderr);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Create Kind 23457 response event
|
||||
cJSON *tags = cJSON_CreateArray();
|
||||
|
||||
// p tag for admin
|
||||
cJSON *p_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(p_tag, cJSON_CreateString("p"));
|
||||
cJSON_AddItemToArray(p_tag, cJSON_CreateString(admin_pubkey));
|
||||
cJSON_AddItemToArray(tags, p_tag);
|
||||
|
||||
// e tag for request correlation
|
||||
cJSON *e_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(e_tag, cJSON_CreateString("e"));
|
||||
cJSON_AddItemToArray(e_tag, cJSON_CreateString(request_id));
|
||||
cJSON_AddItemToArray(tags, e_tag);
|
||||
|
||||
// Sign the event
|
||||
cJSON *signed_event = nostr_create_and_sign_event(
|
||||
23457,
|
||||
encrypted_response,
|
||||
tags,
|
||||
server_privkey,
|
||||
time(NULL)
|
||||
);
|
||||
|
||||
if (!signed_event) {
|
||||
fprintf(stderr, "[WebSocket] Failed to sign response event\n");
|
||||
fflush(stderr);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Serialize event to JSON
|
||||
char *event_json = cJSON_PrintUnformatted(signed_event);
|
||||
cJSON_Delete(signed_event);
|
||||
|
||||
if (!event_json) {
|
||||
fprintf(stderr, "[WebSocket] Failed to serialize event\n");
|
||||
fflush(stderr);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Store response in session for sending
|
||||
size_t json_len = strlen(event_json);
|
||||
if (json_len + LWS_PRE < sizeof(pss->pending_response)) {
|
||||
memcpy(&pss->pending_response[LWS_PRE], event_json, json_len);
|
||||
pss->pending_response_len = LWS_PRE + json_len;
|
||||
fprintf(stderr, "[WebSocket] Response prepared (%zu bytes)\n", json_len);
|
||||
fflush(stderr);
|
||||
} else {
|
||||
fprintf(stderr, "[WebSocket] Response too large\n");
|
||||
fflush(stderr);
|
||||
}
|
||||
|
||||
free(event_json);
|
||||
return 0;
|
||||
} else {
|
||||
cJSON_Delete(response_data);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server private key from database
|
||||
*/
|
||||
static int get_server_privkey(unsigned char* privkey_bytes) {
|
||||
sqlite3 *db;
|
||||
int rc = sqlite3_open_v2(g_db_path, &db, SQLITE_OPEN_READONLY, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
sqlite3_stmt *stmt;
|
||||
const char *sql = "SELECT seckey FROM blossom_seckey LIMIT 1";
|
||||
int result = -1;
|
||||
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) == SQLITE_OK) {
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char *privkey_hex = (const char*)sqlite3_column_text(stmt, 0);
|
||||
if (privkey_hex && nostr_hex_to_bytes(privkey_hex, privkey_bytes, 32) == 0) {
|
||||
result = 0;
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
sqlite3_close(db);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server public key from database
|
||||
*/
|
||||
static int get_server_pubkey(char* pubkey_hex, size_t size) {
|
||||
sqlite3 *db;
|
||||
int rc = sqlite3_open_v2(g_db_path, &db, SQLITE_OPEN_READONLY, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
sqlite3_stmt *stmt;
|
||||
const char *sql = "SELECT value FROM config WHERE key = 'blossom_pubkey'";
|
||||
int result = -1;
|
||||
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) == SQLITE_OK) {
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char *pubkey = (const char*)sqlite3_column_text(stmt, 0);
|
||||
if (pubkey) {
|
||||
strncpy(pubkey_hex, pubkey, size - 1);
|
||||
pubkey_hex[size - 1] = '\0';
|
||||
result = 0;
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
sqlite3_close(db);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle config_query command
|
||||
*/
|
||||
static int handle_config_query_command(cJSON* response_data) {
|
||||
sqlite3 *db;
|
||||
int rc = sqlite3_open_v2(g_db_path, &db, SQLITE_OPEN_READONLY, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
cJSON_AddStringToObject(response_data, "status", "error");
|
||||
cJSON_AddStringToObject(response_data, "error", "Database error");
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(response_data, "status", "success");
|
||||
cJSON *data = cJSON_CreateObject();
|
||||
|
||||
// Query all config settings
|
||||
sqlite3_stmt *stmt;
|
||||
const char *sql = "SELECT key, value FROM config ORDER BY key";
|
||||
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) == SQLITE_OK) {
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char *key = (const char*)sqlite3_column_text(stmt, 0);
|
||||
const char *value = (const char*)sqlite3_column_text(stmt, 1);
|
||||
if (key && value) {
|
||||
cJSON_AddStringToObject(data, key, value);
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
cJSON_AddItemToObject(response_data, "data", data);
|
||||
sqlite3_close(db);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket server thread
|
||||
*/
|
||||
void* admin_websocket_thread(void* arg) {
|
||||
int port = *(int*)arg;
|
||||
|
||||
struct lws_context_creation_info info;
|
||||
memset(&info, 0, sizeof(info));
|
||||
|
||||
info.port = port;
|
||||
info.iface = "127.0.0.1"; // Force IPv4 binding for localhost compatibility
|
||||
info.protocols = protocols;
|
||||
info.gid = -1;
|
||||
info.uid = -1;
|
||||
info.options = LWS_SERVER_OPTION_VALIDATE_UTF8 | LWS_SERVER_OPTION_DISABLE_IPV6;
|
||||
|
||||
fprintf(stderr, "[WebSocket] Starting admin WebSocket server on 127.0.0.1:%d (IPv4 only)\n", port);
|
||||
fflush(stderr);
|
||||
|
||||
ws_context = lws_create_context(&info);
|
||||
if (!ws_context) {
|
||||
fprintf(stderr, "[WebSocket] Failed to create context\n");
|
||||
fflush(stderr);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
fprintf(stderr, "[WebSocket] Server started successfully\n");
|
||||
fflush(stderr);
|
||||
|
||||
// Service loop
|
||||
while (!force_exit) {
|
||||
lws_service(ws_context, 50);
|
||||
}
|
||||
|
||||
lws_context_destroy(ws_context);
|
||||
fprintf(stderr, "[WebSocket] Server stopped\n");
|
||||
fflush(stderr);
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start admin WebSocket server
|
||||
*/
|
||||
int start_admin_websocket_server(int port) {
|
||||
static int server_port;
|
||||
server_port = port;
|
||||
|
||||
pthread_t thread;
|
||||
int result = pthread_create(&thread, NULL, admin_websocket_thread, &server_port);
|
||||
if (result != 0) {
|
||||
fprintf(stderr, "[WebSocket] Failed to create thread: %d\n", result);
|
||||
fflush(stderr);
|
||||
return -1;
|
||||
}
|
||||
|
||||
pthread_detach(thread);
|
||||
fprintf(stderr, "[WebSocket] Thread started\n");
|
||||
fflush(stderr);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop admin WebSocket server
|
||||
*/
|
||||
void stop_admin_websocket_server(void) {
|
||||
force_exit = 1;
|
||||
}
|
||||
@@ -10,8 +10,8 @@
|
||||
// Version information (auto-updated by build system)
|
||||
#define VERSION_MAJOR 0
|
||||
#define VERSION_MINOR 1
|
||||
#define VERSION_PATCH 11
|
||||
#define VERSION "v0.1.11"
|
||||
#define VERSION_PATCH 14
|
||||
#define VERSION "v0.1.14"
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
@@ -250,6 +250,16 @@ void send_json_response(int status_code, const char* json_content);
|
||||
// Logging utilities
|
||||
void log_request(const char* method, const char* uri, const char* auth_status, int status_code);
|
||||
|
||||
// Centralized application logging (writes to logs/app/app.log)
|
||||
typedef enum {
|
||||
LOG_DEBUG = 0,
|
||||
LOG_INFO = 1,
|
||||
LOG_WARN = 2,
|
||||
LOG_ERROR = 3
|
||||
} log_level_t;
|
||||
|
||||
void app_log(log_level_t level, const char* format, ...);
|
||||
|
||||
// SHA-256 validation helper (used by multiple BUDs)
|
||||
int validate_sha256_format(const char* sha256);
|
||||
|
||||
@@ -283,10 +293,6 @@ void send_json_response(int status, const char* json_content);
|
||||
void send_json_error(int status, const char* error, const char* message);
|
||||
int parse_query_params(const char* query_string, char params[][256], int max_params);
|
||||
|
||||
// Admin WebSocket server functions
|
||||
int start_admin_websocket_server(int port);
|
||||
void stop_admin_websocket_server(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
188
src/main.c
188
src/main.c
@@ -5,11 +5,14 @@
|
||||
|
||||
#define _GNU_SOURCE
|
||||
#include "ginxsom.h"
|
||||
#include "relay_client.h"
|
||||
#include "admin_commands.h"
|
||||
#include "../nostr_core_lib/nostr_core/nostr_common.h"
|
||||
#include "../nostr_core_lib/nostr_core/utils.h"
|
||||
#include <getopt.h>
|
||||
#include <curl/curl.h>
|
||||
#include <sqlite3.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -19,7 +22,43 @@
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
// Debug macros removed
|
||||
// Centralized logging system (declaration in ginxsom.h)
|
||||
void app_log(log_level_t level, const char *format, ...) {
|
||||
FILE *log_file = fopen("logs/app/app.log", "a");
|
||||
if (!log_file) {
|
||||
return; // Silently fail if we can't open log file
|
||||
}
|
||||
|
||||
// Get timestamp
|
||||
time_t now = time(NULL);
|
||||
struct tm *tm_info = localtime(&now);
|
||||
char timestamp[64];
|
||||
strftime(timestamp, sizeof(timestamp), "%Y-%m-%d %H:%M:%S", tm_info);
|
||||
|
||||
// Get log level string
|
||||
const char *level_str;
|
||||
switch (level) {
|
||||
case LOG_DEBUG: level_str = "DEBUG"; break;
|
||||
case LOG_INFO: level_str = "INFO"; break;
|
||||
case LOG_WARN: level_str = "WARN"; break;
|
||||
case LOG_ERROR: level_str = "ERROR"; break;
|
||||
default: level_str = "UNKNOWN"; break;
|
||||
}
|
||||
|
||||
// Write log prefix with timestamp, PID, and level
|
||||
fprintf(log_file, "[%s] [PID:%d] [%s] ", timestamp, getpid(), level_str);
|
||||
|
||||
// Write formatted message
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
vfprintf(log_file, format, args);
|
||||
va_end(args);
|
||||
|
||||
// Ensure newline
|
||||
fprintf(log_file, "\n");
|
||||
|
||||
fclose(log_file);
|
||||
}
|
||||
|
||||
#define MAX_SHA256_LEN 65
|
||||
#define MAX_PATH_LEN 4096
|
||||
@@ -196,7 +235,10 @@ int initialize_database(const char *db_path) {
|
||||
" ('admin_enabled', 'true', 'Whether admin API is enabled'),"
|
||||
" ('nip42_require_auth', 'false', 'Enable NIP-42 challenge/response authentication'),"
|
||||
" ('nip42_challenge_timeout', '600', 'NIP-42 challenge timeout in seconds'),"
|
||||
" ('nip42_time_tolerance', '300', 'NIP-42 timestamp tolerance in seconds');";
|
||||
" ('nip42_time_tolerance', '300', 'NIP-42 timestamp tolerance in seconds'),"
|
||||
" ('enable_relay_connect', 'true', 'Enable connection to Nostr relays'),"
|
||||
" ('kind_0_content', '{\"name\":\"Ginxsom Blossom Server\",\"about\":\"A Nostr-enabled Blossom media server\",\"picture\":\"\"}', 'JSON content for Kind 0 profile event'),"
|
||||
" ('kind_10002_tags', '[\"wss://relay.laantungir.net\"]', 'JSON array of relay URLs for Kind 10002');";
|
||||
|
||||
rc = sqlite3_exec(db, insert_config, NULL, NULL, &err_msg);
|
||||
if (rc != SQLITE_OK) {
|
||||
@@ -1830,18 +1872,9 @@ void handle_auth_challenge_request(void) {
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
// Redirect stderr to log file BEFORE any other operations
|
||||
// This is necessary because spawn-fcgi doesn't preserve stderr redirections
|
||||
FILE *stderr_log = freopen("logs/app/stderr.log", "a", stderr);
|
||||
if (!stderr_log) {
|
||||
// If redirection fails, continue anyway but log to original stderr
|
||||
perror("Warning: Failed to redirect stderr to log file");
|
||||
}
|
||||
|
||||
// Set stderr to unbuffered mode so all fprintf(stderr, ...) calls flush immediately
|
||||
setvbuf(stderr, NULL, _IONBF, 0);
|
||||
|
||||
fprintf(stderr, "DEBUG: main() started\n");
|
||||
// Initialize application logging
|
||||
app_log(LOG_INFO, "=== Ginxsom FastCGI Application Starting ===");
|
||||
app_log(LOG_INFO, "Process ID: %d", getpid());
|
||||
|
||||
// Parse command line arguments
|
||||
int use_test_keys = 0;
|
||||
@@ -1901,17 +1934,16 @@ int main(int argc, char *argv[]) {
|
||||
}
|
||||
}
|
||||
|
||||
fprintf(stderr, "STARTUP: Using storage directory: %s\n", g_storage_dir);
|
||||
app_log(LOG_INFO, "Storage directory: %s", g_storage_dir);
|
||||
|
||||
// CRITICAL: Initialize nostr crypto system BEFORE key operations
|
||||
fprintf(stderr, "STARTUP: Initializing nostr crypto system...\r\n");
|
||||
app_log(LOG_INFO, "Initializing nostr crypto system...");
|
||||
int crypto_init_result = nostr_crypto_init();
|
||||
fprintf(stderr, "CRYPTO INIT RESULT: %d\r\n", crypto_init_result);
|
||||
if (crypto_init_result != 0) {
|
||||
fprintf(stderr, "FATAL ERROR: Failed to initialize nostr crypto system\r\n");
|
||||
app_log(LOG_ERROR, "Failed to initialize nostr crypto system (result: %d)", crypto_init_result);
|
||||
return 1;
|
||||
}
|
||||
fprintf(stderr, "STARTUP: nostr crypto system initialized successfully\r\n");
|
||||
app_log(LOG_INFO, "Nostr crypto system initialized successfully");
|
||||
|
||||
// ========================================================================
|
||||
// DATABASE AND KEY INITIALIZATION - 5 SCENARIOS
|
||||
@@ -1919,12 +1951,12 @@ int main(int argc, char *argv[]) {
|
||||
|
||||
// Scenario 4: Test Mode (--test-keys)
|
||||
if (use_test_keys) {
|
||||
fprintf(stderr, "\n=== SCENARIO 4: TEST MODE ===\n");
|
||||
app_log(LOG_INFO, "=== SCENARIO 4: TEST MODE ===");
|
||||
|
||||
// Load test keys from .test_keys file
|
||||
FILE *keys_file = fopen(".test_keys", "r");
|
||||
if (!keys_file) {
|
||||
fprintf(stderr, "ERROR: Cannot open .test_keys file\n");
|
||||
app_log(LOG_ERROR, "Cannot open .test_keys file");
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -1944,38 +1976,38 @@ int main(int argc, char *argv[]) {
|
||||
if (end && (end - start) == 64) {
|
||||
strncpy(test_server_privkey, start, 64);
|
||||
test_server_privkey[64] = '\0';
|
||||
fprintf(stderr, "TEST MODE: Parsed SERVER_PRIVKEY: %s\n", test_server_privkey);
|
||||
app_log(LOG_DEBUG, "Parsed SERVER_PRIVKEY from .test_keys");
|
||||
} else {
|
||||
fprintf(stderr, "TEST MODE: Failed to parse SERVER_PRIVKEY (length: %ld)\n", end ? (long)(end - start) : -1L);
|
||||
app_log(LOG_ERROR, "Failed to parse SERVER_PRIVKEY (length: %ld)", end ? (long)(end - start) : -1L);
|
||||
}
|
||||
}
|
||||
}
|
||||
fclose(keys_file);
|
||||
|
||||
fprintf(stderr, "TEST MODE: Loaded keys from .test_keys\n");
|
||||
fprintf(stderr, "TEST MODE: Admin pubkey: %s\n", g_admin_pubkey);
|
||||
app_log(LOG_INFO, "Loaded keys from .test_keys");
|
||||
app_log(LOG_INFO, "Admin pubkey: %s", g_admin_pubkey);
|
||||
|
||||
// Derive pubkey from test privkey
|
||||
if (derive_pubkey_from_privkey(test_server_privkey, g_blossom_pubkey) != 0) {
|
||||
fprintf(stderr, "ERROR: Failed to derive pubkey from test privkey\n");
|
||||
app_log(LOG_ERROR, "Failed to derive pubkey from test privkey");
|
||||
return 1;
|
||||
}
|
||||
|
||||
fprintf(stderr, "TEST MODE: Server pubkey: %s\n", g_blossom_pubkey);
|
||||
app_log(LOG_INFO, "Server pubkey: %s", g_blossom_pubkey);
|
||||
|
||||
// Set database path based on test pubkey
|
||||
if (set_db_path_from_pubkey(g_blossom_pubkey) != 0) {
|
||||
fprintf(stderr, "ERROR: Failed to set database path\n");
|
||||
app_log(LOG_ERROR, "Failed to set database path");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Test mode ALWAYS overwrites database for clean testing
|
||||
fprintf(stderr, "TEST MODE: Creating/overwriting database: %s\n", g_db_path);
|
||||
app_log(LOG_INFO, "Creating/overwriting test database: %s", g_db_path);
|
||||
unlink(g_db_path); // Remove if exists
|
||||
|
||||
// Initialize new database
|
||||
if (initialize_database(g_db_path) != 0) {
|
||||
fprintf(stderr, "ERROR: Failed to initialize test database\n");
|
||||
app_log(LOG_ERROR, "Failed to initialize test database");
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -1984,7 +2016,7 @@ int main(int argc, char *argv[]) {
|
||||
g_blossom_seckey[64] = '\0';
|
||||
|
||||
if (store_blossom_private_key(test_server_privkey) != 0) {
|
||||
fprintf(stderr, "ERROR: Failed to store test private key\n");
|
||||
app_log(LOG_ERROR, "Failed to store test private key");
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -2016,12 +2048,12 @@ int main(int argc, char *argv[]) {
|
||||
sqlite3_close(db);
|
||||
}
|
||||
|
||||
fprintf(stderr, "TEST MODE: Database initialized successfully\n");
|
||||
app_log(LOG_INFO, "Test database initialized successfully");
|
||||
}
|
||||
|
||||
// Scenario 3: Keys Specified (--server-privkey)
|
||||
else if (test_server_privkey[0] != '\0') {
|
||||
fprintf(stderr, "\n=== SCENARIO 3: KEYS SPECIFIED ===\n");
|
||||
app_log(LOG_INFO, "=== SCENARIO 3: KEYS SPECIFIED ===");
|
||||
|
||||
// Derive pubkey from provided privkey
|
||||
if (derive_pubkey_from_privkey(test_server_privkey, g_blossom_pubkey) != 0) {
|
||||
@@ -2171,21 +2203,18 @@ int main(int argc, char *argv[]) {
|
||||
// END DATABASE AND KEY INITIALIZATION
|
||||
// ========================================================================
|
||||
|
||||
fprintf(stderr, "\n=== FINAL CONFIGURATION ===\n");
|
||||
fprintf(stderr, "Database path: %s\n", g_db_path);
|
||||
fprintf(stderr, "Storage directory: %s\n", g_storage_dir);
|
||||
fprintf(stderr, "Server pubkey: %s\n", g_blossom_pubkey);
|
||||
app_log(LOG_INFO, "=== FINAL CONFIGURATION ===");
|
||||
app_log(LOG_INFO, "Database path: %s", g_db_path);
|
||||
app_log(LOG_INFO, "Storage directory: %s", g_storage_dir);
|
||||
app_log(LOG_INFO, "Server pubkey: %s", g_blossom_pubkey);
|
||||
if (strlen(g_admin_pubkey) > 0) {
|
||||
fprintf(stderr, "Admin pubkey: %s\n", g_admin_pubkey);
|
||||
app_log(LOG_INFO, "Admin pubkey: %s", g_admin_pubkey);
|
||||
}
|
||||
fprintf(stderr, "===========================\n\n");
|
||||
|
||||
fflush(stderr);
|
||||
app_log(LOG_INFO, "===========================");
|
||||
|
||||
// If --generate-keys was specified, exit after key generation
|
||||
if (g_generate_keys) {
|
||||
fprintf(stderr, "Key generation completed, exiting.\n");
|
||||
fflush(stderr);
|
||||
app_log(LOG_INFO, "Key generation completed, exiting");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -2206,51 +2235,58 @@ if (!config_loaded /* && !initialize_server_config() */) {
|
||||
}
|
||||
|
||||
// Initialize request validator system
|
||||
fprintf(stderr, "STARTUP: Initializing request validator system...\r\n");
|
||||
app_log(LOG_INFO, "Initializing request validator system...");
|
||||
int validator_init_result =
|
||||
ginxsom_request_validator_init(g_db_path, "ginxsom");
|
||||
fprintf(stderr, "MAIN: validator init return code: %d\r\n",
|
||||
validator_init_result);
|
||||
if (validator_init_result != NOSTR_SUCCESS) {
|
||||
fprintf(stderr,
|
||||
"FATAL ERROR: Failed to initialize request validator system\r\n");
|
||||
app_log(LOG_ERROR, "Failed to initialize request validator system (result: %d)", validator_init_result);
|
||||
return 1;
|
||||
}
|
||||
fprintf(stderr,
|
||||
"STARTUP: Request validator system initialized successfully\r\n");
|
||||
fflush(stderr);
|
||||
app_log(LOG_INFO, "Request validator system initialized successfully");
|
||||
|
||||
// Start WebSocket admin server if enabled
|
||||
sqlite3 *db;
|
||||
sqlite3_stmt *stmt;
|
||||
int rc = sqlite3_open_v2(g_db_path, &db, SQLITE_OPEN_READONLY, NULL);
|
||||
if (rc == SQLITE_OK) {
|
||||
const char *sql = "SELECT value FROM config WHERE key = 'admin_enabled'";
|
||||
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
|
||||
if (rc == SQLITE_OK) {
|
||||
rc = sqlite3_step(stmt);
|
||||
if (rc == SQLITE_ROW) {
|
||||
const char *admin_enabled = (const char *)sqlite3_column_text(stmt, 0);
|
||||
if (admin_enabled && (strcmp(admin_enabled, "true") == 0 || strcmp(admin_enabled, "1") == 0)) {
|
||||
fprintf(stderr, "STARTUP: Starting WebSocket admin server on port 9442...\n");
|
||||
if (start_admin_websocket_server(9442) == 0) {
|
||||
fprintf(stderr, "STARTUP: WebSocket admin server started successfully\n");
|
||||
} else {
|
||||
fprintf(stderr, "WARNING: Failed to start WebSocket admin server\n");
|
||||
}
|
||||
} else {
|
||||
fprintf(stderr, "STARTUP: Admin interface disabled in config\n");
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
// Initialize relay client system
|
||||
app_log(LOG_INFO, "Initializing relay client system...");
|
||||
int relay_init_result = relay_client_init(g_db_path);
|
||||
if (relay_init_result != 0) {
|
||||
app_log(LOG_WARN, "Failed to initialize relay client system (result: %d)", relay_init_result);
|
||||
app_log(LOG_WARN, "Continuing without relay client functionality");
|
||||
} else {
|
||||
app_log(LOG_INFO, "Relay client system initialized successfully");
|
||||
|
||||
// Start relay connections (this will check enable_relay_connect config)
|
||||
app_log(LOG_INFO, "Starting relay client connections...");
|
||||
int relay_start_result = relay_client_start();
|
||||
if (relay_start_result != 0) {
|
||||
app_log(LOG_WARN, "Failed to start relay client (result: %d)", relay_start_result);
|
||||
app_log(LOG_WARN, "Relay client disabled - check configuration");
|
||||
} else {
|
||||
app_log(LOG_INFO, "Relay client started successfully");
|
||||
}
|
||||
sqlite3_close(db);
|
||||
}
|
||||
|
||||
|
||||
// Initialize admin commands system
|
||||
app_log(LOG_INFO, "Initializing admin commands system...");
|
||||
int admin_cmd_result = admin_commands_init(g_db_path);
|
||||
if (admin_cmd_result != 0) {
|
||||
app_log(LOG_WARN, "Failed to initialize admin commands system (result: %d)", admin_cmd_result);
|
||||
app_log(LOG_WARN, "Continuing without admin commands functionality");
|
||||
} else {
|
||||
app_log(LOG_INFO, "Admin commands system initialized successfully");
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// THIS IS WHERE THE REQUESTS ENTER THE FastCGI
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
app_log(LOG_INFO, "FastCGI request loop starting - ready to accept requests");
|
||||
|
||||
int first_request = 1;
|
||||
while (FCGI_Accept() >= 0) {
|
||||
// Test stderr capture on first request
|
||||
if (first_request) {
|
||||
fprintf(stderr, "FCGI: First request received - testing nginx stderr capture\n");
|
||||
fflush(stderr);
|
||||
first_request = 0;
|
||||
}
|
||||
const char *request_method = getenv("REQUEST_METHOD");
|
||||
const char *request_uri = getenv("REQUEST_URI");
|
||||
const char *auth_header = getenv("HTTP_AUTHORIZATION");
|
||||
|
||||
871
src/relay_client.c
Normal file
871
src/relay_client.c
Normal file
@@ -0,0 +1,871 @@
|
||||
/*
|
||||
* Ginxsom Relay Client Implementation
|
||||
*
|
||||
* Manages connections to Nostr relays, publishes events, and subscribes to admin commands.
|
||||
*/
|
||||
|
||||
#include "relay_client.h"
|
||||
#include "admin_commands.h"
|
||||
#include "../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
#include <sqlite3.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <pthread.h>
|
||||
#include <unistd.h>
|
||||
#include <time.h>
|
||||
|
||||
// Forward declare app_log to avoid including ginxsom.h (which has typedef conflicts)
|
||||
typedef enum {
|
||||
LOG_DEBUG = 0,
|
||||
LOG_INFO = 1,
|
||||
LOG_WARN = 2,
|
||||
LOG_ERROR = 3
|
||||
} log_level_t;
|
||||
|
||||
void app_log(log_level_t level, const char* format, ...);
|
||||
|
||||
// Maximum number of relays to connect to
|
||||
#define MAX_RELAYS 10
|
||||
|
||||
// Reconnection settings
|
||||
#define RECONNECT_DELAY_SECONDS 30
|
||||
#define MAX_RECONNECT_ATTEMPTS 5
|
||||
|
||||
// Global state
|
||||
static struct {
|
||||
int enabled;
|
||||
int initialized;
|
||||
int running;
|
||||
char db_path[512];
|
||||
nostr_relay_pool_t* pool;
|
||||
char** relay_urls;
|
||||
int relay_count;
|
||||
nostr_pool_subscription_t* admin_subscription;
|
||||
pthread_t management_thread;
|
||||
pthread_mutex_t state_mutex;
|
||||
} g_relay_state = {0};
|
||||
|
||||
// External globals from main.c
|
||||
extern char g_blossom_seckey[65];
|
||||
extern char g_blossom_pubkey[65];
|
||||
extern char g_admin_pubkey[65];
|
||||
|
||||
// Forward declarations
|
||||
static void *relay_management_thread(void *arg);
|
||||
static int load_config_from_db(void);
|
||||
static int parse_relay_urls(const char *json_array);
|
||||
static int subscribe_to_admin_commands(void);
|
||||
static void on_publish_response(const char* relay_url, const char* event_id, int success, const char* message, void* user_data);
|
||||
static void on_admin_command_event(cJSON* event, const char* relay_url, void* user_data);
|
||||
static void on_admin_subscription_eose(cJSON** events, int event_count, void* user_data);
|
||||
|
||||
// Initialize relay client system
|
||||
int relay_client_init(const char *db_path) {
|
||||
if (g_relay_state.initialized) {
|
||||
app_log(LOG_WARN, "Relay client already initialized");
|
||||
return 0;
|
||||
}
|
||||
|
||||
app_log(LOG_INFO, "Initializing relay client system...");
|
||||
|
||||
// Store database path
|
||||
strncpy(g_relay_state.db_path, db_path, sizeof(g_relay_state.db_path) - 1);
|
||||
|
||||
// Initialize mutex
|
||||
if (pthread_mutex_init(&g_relay_state.state_mutex, NULL) != 0) {
|
||||
app_log(LOG_ERROR, "Failed to initialize relay state mutex");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Load configuration from database
|
||||
if (load_config_from_db() != 0) {
|
||||
app_log(LOG_ERROR, "Failed to load relay configuration from database");
|
||||
pthread_mutex_destroy(&g_relay_state.state_mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Create relay pool if enabled
|
||||
if (g_relay_state.enabled) {
|
||||
// Use default reconnection config (don't free - it's a static structure)
|
||||
nostr_pool_reconnect_config_t* config = nostr_pool_reconnect_config_default();
|
||||
g_relay_state.pool = nostr_relay_pool_create(config);
|
||||
if (!g_relay_state.pool) {
|
||||
app_log(LOG_ERROR, "Failed to create relay pool");
|
||||
pthread_mutex_destroy(&g_relay_state.state_mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Add all relays to pool
|
||||
for (int i = 0; i < g_relay_state.relay_count; i++) {
|
||||
if (nostr_relay_pool_add_relay(g_relay_state.pool, g_relay_state.relay_urls[i]) != NOSTR_SUCCESS) {
|
||||
app_log(LOG_WARN, "Failed to add relay to pool: %s", g_relay_state.relay_urls[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger initial connection attempts by creating a dummy subscription
|
||||
// This forces ensure_relay_connection() to be called for each relay
|
||||
app_log(LOG_INFO, "Initiating relay connections...");
|
||||
cJSON* dummy_filter = cJSON_CreateObject();
|
||||
cJSON* kinds = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(0)); // Kind 0 (will match nothing)
|
||||
cJSON_AddItemToObject(dummy_filter, "kinds", kinds);
|
||||
cJSON_AddNumberToObject(dummy_filter, "limit", 0); // Limit 0 = no results
|
||||
|
||||
nostr_pool_subscription_t* dummy_sub = nostr_relay_pool_subscribe(
|
||||
g_relay_state.pool,
|
||||
(const char**)g_relay_state.relay_urls,
|
||||
g_relay_state.relay_count,
|
||||
dummy_filter,
|
||||
NULL, // No event callback
|
||||
NULL, // No EOSE callback
|
||||
NULL, // No user data
|
||||
1, // close_on_eose
|
||||
1, // enable_deduplication
|
||||
NOSTR_POOL_EOSE_FIRST, // result_mode
|
||||
30, // relay_timeout_seconds
|
||||
30 // eose_timeout_seconds
|
||||
);
|
||||
|
||||
cJSON_Delete(dummy_filter);
|
||||
|
||||
// Immediately close the dummy subscription
|
||||
if (dummy_sub) {
|
||||
nostr_pool_subscription_close(dummy_sub);
|
||||
app_log(LOG_INFO, "Connection attempts initiated for %d relays", g_relay_state.relay_count);
|
||||
} else {
|
||||
app_log(LOG_WARN, "Failed to initiate connection attempts");
|
||||
}
|
||||
}
|
||||
|
||||
g_relay_state.initialized = 1;
|
||||
app_log(LOG_INFO, "Relay client initialized (enabled: %d, relays: %d)",
|
||||
g_relay_state.enabled, g_relay_state.relay_count);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Load configuration from database
|
||||
static int load_config_from_db(void) {
|
||||
sqlite3 *db;
|
||||
sqlite3_stmt *stmt;
|
||||
int rc;
|
||||
|
||||
rc = sqlite3_open_v2(g_relay_state.db_path, &db, SQLITE_OPEN_READONLY, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
app_log(LOG_ERROR, "Cannot open database: %s", sqlite3_errmsg(db));
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Load enable_relay_connect
|
||||
const char *sql = "SELECT value FROM config WHERE key = ?";
|
||||
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
app_log(LOG_ERROR, "Failed to prepare statement: %s", sqlite3_errmsg(db));
|
||||
sqlite3_close(db);
|
||||
return -1;
|
||||
}
|
||||
|
||||
sqlite3_bind_text(stmt, 1, "enable_relay_connect", -1, SQLITE_STATIC);
|
||||
rc = sqlite3_step(stmt);
|
||||
if (rc == SQLITE_ROW) {
|
||||
const char *value = (const char *)sqlite3_column_text(stmt, 0);
|
||||
g_relay_state.enabled = (strcmp(value, "true") == 0 || strcmp(value, "1") == 0);
|
||||
} else {
|
||||
g_relay_state.enabled = 0;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
// If not enabled, skip loading relay URLs
|
||||
if (!g_relay_state.enabled) {
|
||||
sqlite3_close(db);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Load kind_10002_tags (relay URLs)
|
||||
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
app_log(LOG_ERROR, "Failed to prepare statement: %s", sqlite3_errmsg(db));
|
||||
sqlite3_close(db);
|
||||
return -1;
|
||||
}
|
||||
|
||||
sqlite3_bind_text(stmt, 1, "kind_10002_tags", -1, SQLITE_STATIC);
|
||||
rc = sqlite3_step(stmt);
|
||||
if (rc == SQLITE_ROW) {
|
||||
const char *json_array = (const char *)sqlite3_column_text(stmt, 0);
|
||||
if (parse_relay_urls(json_array) != 0) {
|
||||
app_log(LOG_ERROR, "Failed to parse relay URLs from config");
|
||||
sqlite3_finalize(stmt);
|
||||
sqlite3_close(db);
|
||||
return -1;
|
||||
}
|
||||
} else {
|
||||
app_log(LOG_WARN, "No relay URLs configured in kind_10002_tags");
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
sqlite3_close(db);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Parse relay URLs from JSON array
|
||||
static int parse_relay_urls(const char *json_array) {
|
||||
cJSON *root = cJSON_Parse(json_array);
|
||||
if (!root || !cJSON_IsArray(root)) {
|
||||
app_log(LOG_ERROR, "Invalid JSON array for relay URLs");
|
||||
if (root) cJSON_Delete(root);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int count = cJSON_GetArraySize(root);
|
||||
if (count > MAX_RELAYS) {
|
||||
app_log(LOG_WARN, "Too many relays configured (%d), limiting to %d", count, MAX_RELAYS);
|
||||
count = MAX_RELAYS;
|
||||
}
|
||||
|
||||
// Allocate relay URLs array
|
||||
g_relay_state.relay_urls = malloc(count * sizeof(char*));
|
||||
if (!g_relay_state.relay_urls) {
|
||||
cJSON_Delete(root);
|
||||
return -1;
|
||||
}
|
||||
|
||||
g_relay_state.relay_count = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
cJSON *item = cJSON_GetArrayItem(root, i);
|
||||
if (cJSON_IsString(item) && item->valuestring) {
|
||||
g_relay_state.relay_urls[g_relay_state.relay_count] = strdup(item->valuestring);
|
||||
if (!g_relay_state.relay_urls[g_relay_state.relay_count]) {
|
||||
// Cleanup on failure
|
||||
for (int j = 0; j < g_relay_state.relay_count; j++) {
|
||||
free(g_relay_state.relay_urls[j]);
|
||||
}
|
||||
free(g_relay_state.relay_urls);
|
||||
cJSON_Delete(root);
|
||||
return -1;
|
||||
}
|
||||
g_relay_state.relay_count++;
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
app_log(LOG_INFO, "Parsed %d relay URLs from configuration", g_relay_state.relay_count);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Start relay connections
|
||||
int relay_client_start(void) {
|
||||
if (!g_relay_state.initialized) {
|
||||
app_log(LOG_ERROR, "Relay client not initialized");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!g_relay_state.enabled) {
|
||||
app_log(LOG_INFO, "Relay client disabled in configuration");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (g_relay_state.running) {
|
||||
app_log(LOG_WARN, "Relay client already running");
|
||||
return 0;
|
||||
}
|
||||
|
||||
app_log(LOG_INFO, "Starting relay client...");
|
||||
|
||||
// Start management thread
|
||||
g_relay_state.running = 1;
|
||||
if (pthread_create(&g_relay_state.management_thread, NULL, relay_management_thread, NULL) != 0) {
|
||||
app_log(LOG_ERROR, "Failed to create relay management thread");
|
||||
g_relay_state.running = 0;
|
||||
return -1;
|
||||
}
|
||||
|
||||
app_log(LOG_INFO, "Relay client started successfully");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Relay management thread
|
||||
static void *relay_management_thread(void *arg) {
|
||||
(void)arg;
|
||||
|
||||
app_log(LOG_INFO, "Relay management thread started");
|
||||
|
||||
// Wait for at least one relay to connect (max 30 seconds)
|
||||
int connected = 0;
|
||||
for (int i = 0; i < 30 && !connected; i++) {
|
||||
sleep(1);
|
||||
|
||||
// Poll to process connection attempts
|
||||
nostr_relay_pool_poll(g_relay_state.pool, 100);
|
||||
|
||||
// Check if any relay is connected
|
||||
for (int j = 0; j < g_relay_state.relay_count; j++) {
|
||||
nostr_pool_relay_status_t status = nostr_relay_pool_get_relay_status(
|
||||
g_relay_state.pool,
|
||||
g_relay_state.relay_urls[j]
|
||||
);
|
||||
if (status == NOSTR_POOL_RELAY_CONNECTED) {
|
||||
connected = 1;
|
||||
app_log(LOG_INFO, "Relay connected: %s", g_relay_state.relay_urls[j]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
app_log(LOG_WARN, "No relays connected after 30 seconds, continuing anyway");
|
||||
}
|
||||
|
||||
// Publish initial events
|
||||
relay_client_publish_kind0();
|
||||
relay_client_publish_kind10002();
|
||||
|
||||
// Subscribe to admin commands
|
||||
subscribe_to_admin_commands();
|
||||
|
||||
// Main loop: poll the relay pool for incoming messages
|
||||
while (g_relay_state.running) {
|
||||
// Poll with 1000ms timeout
|
||||
int events_processed = nostr_relay_pool_poll(g_relay_state.pool, 1000);
|
||||
|
||||
if (events_processed < 0) {
|
||||
app_log(LOG_ERROR, "Error polling relay pool");
|
||||
sleep(1);
|
||||
}
|
||||
// Pool handles all connection management, reconnection, and message processing
|
||||
}
|
||||
|
||||
app_log(LOG_INFO, "Relay management thread stopping");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Stop relay connections
|
||||
void relay_client_stop(void) {
|
||||
if (!g_relay_state.running) {
|
||||
return;
|
||||
}
|
||||
|
||||
app_log(LOG_INFO, "Stopping relay client...");
|
||||
|
||||
g_relay_state.running = 0;
|
||||
|
||||
// Wait for management thread to finish
|
||||
pthread_join(g_relay_state.management_thread, NULL);
|
||||
|
||||
// Close admin subscription
|
||||
if (g_relay_state.admin_subscription) {
|
||||
nostr_pool_subscription_close(g_relay_state.admin_subscription);
|
||||
g_relay_state.admin_subscription = NULL;
|
||||
}
|
||||
|
||||
// Destroy relay pool (automatically disconnects all relays)
|
||||
if (g_relay_state.pool) {
|
||||
nostr_relay_pool_destroy(g_relay_state.pool);
|
||||
g_relay_state.pool = NULL;
|
||||
}
|
||||
|
||||
// Free relay URLs
|
||||
if (g_relay_state.relay_urls) {
|
||||
for (int i = 0; i < g_relay_state.relay_count; i++) {
|
||||
free(g_relay_state.relay_urls[i]);
|
||||
}
|
||||
free(g_relay_state.relay_urls);
|
||||
g_relay_state.relay_urls = NULL;
|
||||
}
|
||||
|
||||
pthread_mutex_destroy(&g_relay_state.state_mutex);
|
||||
|
||||
app_log(LOG_INFO, "Relay client stopped");
|
||||
}
|
||||
|
||||
// Check if relay client is enabled
|
||||
int relay_client_is_enabled(void) {
|
||||
return g_relay_state.enabled;
|
||||
}
|
||||
|
||||
// Publish Kind 0 profile event
|
||||
int relay_client_publish_kind0(void) {
|
||||
if (!g_relay_state.enabled || !g_relay_state.running || !g_relay_state.pool) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
app_log(LOG_INFO, "Publishing Kind 0 profile event...");
|
||||
|
||||
// Load kind_0_content from database
|
||||
sqlite3 *db;
|
||||
sqlite3_stmt *stmt;
|
||||
int rc;
|
||||
|
||||
rc = sqlite3_open_v2(g_relay_state.db_path, &db, SQLITE_OPEN_READONLY, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
app_log(LOG_ERROR, "Cannot open database: %s", sqlite3_errmsg(db));
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char *sql = "SELECT value FROM config WHERE key = 'kind_0_content'";
|
||||
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
app_log(LOG_ERROR, "Failed to prepare statement: %s", sqlite3_errmsg(db));
|
||||
sqlite3_close(db);
|
||||
return -1;
|
||||
}
|
||||
|
||||
rc = sqlite3_step(stmt);
|
||||
if (rc != SQLITE_ROW) {
|
||||
app_log(LOG_WARN, "No kind_0_content found in config");
|
||||
sqlite3_finalize(stmt);
|
||||
sqlite3_close(db);
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char *content = (const char *)sqlite3_column_text(stmt, 0);
|
||||
|
||||
// Convert private key from hex to bytes
|
||||
unsigned char privkey_bytes[32];
|
||||
if (nostr_hex_to_bytes(g_blossom_seckey, privkey_bytes, 32) != 0) {
|
||||
app_log(LOG_ERROR, "Failed to convert private key from hex");
|
||||
sqlite3_finalize(stmt);
|
||||
sqlite3_close(db);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Create and sign Kind 0 event using nostr_core_lib
|
||||
cJSON* event = nostr_create_and_sign_event(
|
||||
0, // kind
|
||||
content, // content
|
||||
NULL, // tags (empty for Kind 0)
|
||||
privkey_bytes, // private key
|
||||
time(NULL) // created_at
|
||||
);
|
||||
|
||||
sqlite3_finalize(stmt);
|
||||
sqlite3_close(db);
|
||||
|
||||
if (!event) {
|
||||
app_log(LOG_ERROR, "Failed to create Kind 0 event");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Publish to all relays using async pool API
|
||||
int result = nostr_relay_pool_publish_async(
|
||||
g_relay_state.pool,
|
||||
(const char**)g_relay_state.relay_urls,
|
||||
g_relay_state.relay_count,
|
||||
event,
|
||||
on_publish_response,
|
||||
(void*)"Kind 0" // user_data to identify event type
|
||||
);
|
||||
|
||||
cJSON_Delete(event);
|
||||
|
||||
if (result == 0) {
|
||||
app_log(LOG_INFO, "Kind 0 profile event publish initiated");
|
||||
return 0;
|
||||
} else {
|
||||
app_log(LOG_ERROR, "Failed to initiate Kind 0 profile event publish");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Publish Kind 10002 relay list event
|
||||
int relay_client_publish_kind10002(void) {
|
||||
if (!g_relay_state.enabled || !g_relay_state.running || !g_relay_state.pool) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
app_log(LOG_INFO, "Publishing Kind 10002 relay list event...");
|
||||
|
||||
// Build tags array from configured relays
|
||||
cJSON* tags = cJSON_CreateArray();
|
||||
for (int i = 0; i < g_relay_state.relay_count; i++) {
|
||||
cJSON* tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(tag, cJSON_CreateString("r"));
|
||||
cJSON_AddItemToArray(tag, cJSON_CreateString(g_relay_state.relay_urls[i]));
|
||||
cJSON_AddItemToArray(tags, tag);
|
||||
}
|
||||
|
||||
// Convert private key from hex to bytes
|
||||
unsigned char privkey_bytes[32];
|
||||
if (nostr_hex_to_bytes(g_blossom_seckey, privkey_bytes, 32) != 0) {
|
||||
app_log(LOG_ERROR, "Failed to convert private key from hex");
|
||||
cJSON_Delete(tags);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Create and sign Kind 10002 event
|
||||
cJSON* event = nostr_create_and_sign_event(
|
||||
10002, // kind
|
||||
"", // content (empty for Kind 10002)
|
||||
tags, // tags
|
||||
privkey_bytes, // private key
|
||||
time(NULL) // created_at
|
||||
);
|
||||
|
||||
cJSON_Delete(tags);
|
||||
|
||||
if (!event) {
|
||||
app_log(LOG_ERROR, "Failed to create Kind 10002 event");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Publish to all relays using async pool API
|
||||
int result = nostr_relay_pool_publish_async(
|
||||
g_relay_state.pool,
|
||||
(const char**)g_relay_state.relay_urls,
|
||||
g_relay_state.relay_count,
|
||||
event,
|
||||
on_publish_response,
|
||||
(void*)"Kind 10002" // user_data to identify event type
|
||||
);
|
||||
|
||||
cJSON_Delete(event);
|
||||
|
||||
if (result == 0) {
|
||||
app_log(LOG_INFO, "Kind 10002 relay list event publish initiated");
|
||||
return 0;
|
||||
} else {
|
||||
app_log(LOG_ERROR, "Failed to initiate Kind 10002 relay list event publish");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Send Kind 23459 admin response event
|
||||
int relay_client_send_admin_response(const char *recipient_pubkey, const char *response_content) {
|
||||
if (!g_relay_state.enabled || !g_relay_state.running || !g_relay_state.pool) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!recipient_pubkey || !response_content) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
app_log(LOG_INFO, "Sending Kind 23459 admin response to %s", recipient_pubkey);
|
||||
|
||||
// TODO: Encrypt response_content using NIP-44
|
||||
// For now, use plaintext (stub implementation)
|
||||
const char *encrypted_content = response_content;
|
||||
|
||||
// Build tags array
|
||||
cJSON* tags = cJSON_CreateArray();
|
||||
cJSON* p_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(p_tag, cJSON_CreateString("p"));
|
||||
cJSON_AddItemToArray(p_tag, cJSON_CreateString(recipient_pubkey));
|
||||
cJSON_AddItemToArray(tags, p_tag);
|
||||
|
||||
// Convert private key from hex to bytes
|
||||
unsigned char privkey_bytes[32];
|
||||
if (nostr_hex_to_bytes(g_blossom_seckey, privkey_bytes, 32) != 0) {
|
||||
app_log(LOG_ERROR, "Failed to convert private key from hex");
|
||||
cJSON_Delete(tags);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Create and sign Kind 23459 event
|
||||
cJSON* event = nostr_create_and_sign_event(
|
||||
23459, // kind
|
||||
encrypted_content, // content
|
||||
tags, // tags
|
||||
privkey_bytes, // private key
|
||||
time(NULL) // created_at
|
||||
);
|
||||
|
||||
cJSON_Delete(tags);
|
||||
|
||||
if (!event) {
|
||||
app_log(LOG_ERROR, "Failed to create Kind 23459 event");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Publish to all relays using async pool API
|
||||
int result = nostr_relay_pool_publish_async(
|
||||
g_relay_state.pool,
|
||||
(const char**)g_relay_state.relay_urls,
|
||||
g_relay_state.relay_count,
|
||||
event,
|
||||
on_publish_response,
|
||||
(void*)"Kind 23459" // user_data to identify event type
|
||||
);
|
||||
|
||||
cJSON_Delete(event);
|
||||
|
||||
if (result == 0) {
|
||||
app_log(LOG_INFO, "Kind 23459 admin response publish initiated");
|
||||
return 0;
|
||||
} else {
|
||||
app_log(LOG_ERROR, "Failed to initiate Kind 23459 admin response publish");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Callback for publish responses
|
||||
static void on_publish_response(const char* relay_url, const char* event_id, int success, const char* message, void* user_data) {
|
||||
const char* event_type = (const char*)user_data;
|
||||
|
||||
if (success) {
|
||||
app_log(LOG_INFO, "%s event published successfully to %s (ID: %s)",
|
||||
event_type, relay_url, event_id);
|
||||
} else {
|
||||
app_log(LOG_WARN, "%s event rejected by %s: %s",
|
||||
event_type, relay_url, message ? message : "unknown error");
|
||||
}
|
||||
}
|
||||
|
||||
// Callback for received Kind 23458 admin command events
|
||||
static void on_admin_command_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
(void)user_data;
|
||||
|
||||
app_log(LOG_INFO, "Received Kind 23458 admin command from relay: %s", relay_url);
|
||||
|
||||
// Extract event fields
|
||||
cJSON* kind_json = cJSON_GetObjectItem(event, "kind");
|
||||
cJSON* pubkey_json = cJSON_GetObjectItem(event, "pubkey");
|
||||
cJSON* content_json = cJSON_GetObjectItem(event, "content");
|
||||
cJSON* id_json = cJSON_GetObjectItem(event, "id");
|
||||
|
||||
if (!kind_json || !pubkey_json || !content_json || !id_json) {
|
||||
app_log(LOG_ERROR, "Invalid event structure");
|
||||
return;
|
||||
}
|
||||
|
||||
int kind = cJSON_GetNumberValue(kind_json);
|
||||
const char* sender_pubkey = cJSON_GetStringValue(pubkey_json);
|
||||
const char* encrypted_content = cJSON_GetStringValue(content_json);
|
||||
const char* event_id = cJSON_GetStringValue(id_json);
|
||||
|
||||
if (kind != 23458) {
|
||||
app_log(LOG_WARN, "Unexpected event kind: %d", kind);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify sender is admin
|
||||
if (strcmp(sender_pubkey, g_admin_pubkey) != 0) {
|
||||
app_log(LOG_WARN, "Ignoring command from non-admin pubkey: %s", sender_pubkey);
|
||||
return;
|
||||
}
|
||||
|
||||
app_log(LOG_INFO, "Processing admin command (event ID: %s)", event_id);
|
||||
|
||||
// Convert keys from hex to bytes
|
||||
unsigned char server_privkey[32];
|
||||
unsigned char admin_pubkey_bytes[32];
|
||||
|
||||
if (nostr_hex_to_bytes(g_blossom_seckey, server_privkey, 32) != 0) {
|
||||
app_log(LOG_ERROR, "Failed to convert server private key from hex");
|
||||
return;
|
||||
}
|
||||
|
||||
if (nostr_hex_to_bytes(sender_pubkey, admin_pubkey_bytes, 32) != 0) {
|
||||
app_log(LOG_ERROR, "Failed to convert admin public key from hex");
|
||||
return;
|
||||
}
|
||||
|
||||
// Decrypt command content using NIP-44
|
||||
char decrypted_command[4096];
|
||||
if (admin_decrypt_command(server_privkey, admin_pubkey_bytes, encrypted_content,
|
||||
decrypted_command, sizeof(decrypted_command)) != 0) {
|
||||
app_log(LOG_ERROR, "Failed to decrypt admin command");
|
||||
|
||||
// Send error response
|
||||
cJSON* error_response = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(error_response, "status", "error");
|
||||
cJSON_AddStringToObject(error_response, "message", "Failed to decrypt command");
|
||||
char* error_json = cJSON_PrintUnformatted(error_response);
|
||||
cJSON_Delete(error_response);
|
||||
|
||||
char encrypted_response[4096];
|
||||
if (admin_encrypt_response(server_privkey, admin_pubkey_bytes, error_json,
|
||||
encrypted_response, sizeof(encrypted_response)) == 0) {
|
||||
relay_client_send_admin_response(sender_pubkey, encrypted_response);
|
||||
}
|
||||
free(error_json);
|
||||
return;
|
||||
}
|
||||
|
||||
app_log(LOG_DEBUG, "Decrypted command: %s", decrypted_command);
|
||||
|
||||
// Parse command JSON
|
||||
cJSON* command_json = cJSON_Parse(decrypted_command);
|
||||
if (!command_json) {
|
||||
app_log(LOG_ERROR, "Failed to parse command JSON");
|
||||
|
||||
cJSON* error_response = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(error_response, "status", "error");
|
||||
cJSON_AddStringToObject(error_response, "message", "Invalid JSON format");
|
||||
char* error_json = cJSON_PrintUnformatted(error_response);
|
||||
cJSON_Delete(error_response);
|
||||
|
||||
char encrypted_response[4096];
|
||||
if (admin_encrypt_response(server_privkey, admin_pubkey_bytes, error_json,
|
||||
encrypted_response, sizeof(encrypted_response)) == 0) {
|
||||
relay_client_send_admin_response(sender_pubkey, encrypted_response);
|
||||
}
|
||||
free(error_json);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process command and get response
|
||||
cJSON* response_json = admin_commands_process(command_json, event_id);
|
||||
cJSON_Delete(command_json);
|
||||
|
||||
if (!response_json) {
|
||||
app_log(LOG_ERROR, "Failed to process admin command");
|
||||
response_json = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(response_json, "status", "error");
|
||||
cJSON_AddStringToObject(response_json, "message", "Failed to process command");
|
||||
}
|
||||
|
||||
// Convert response to JSON string
|
||||
char* response_str = cJSON_PrintUnformatted(response_json);
|
||||
cJSON_Delete(response_json);
|
||||
|
||||
if (!response_str) {
|
||||
app_log(LOG_ERROR, "Failed to serialize response JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
// Encrypt and send response
|
||||
char encrypted_response[4096];
|
||||
if (admin_encrypt_response(server_privkey, admin_pubkey_bytes, response_str,
|
||||
encrypted_response, sizeof(encrypted_response)) != 0) {
|
||||
app_log(LOG_ERROR, "Failed to encrypt admin response");
|
||||
free(response_str);
|
||||
return;
|
||||
}
|
||||
|
||||
free(response_str);
|
||||
|
||||
if (relay_client_send_admin_response(sender_pubkey, encrypted_response) != 0) {
|
||||
app_log(LOG_ERROR, "Failed to send admin response");
|
||||
}
|
||||
}
|
||||
|
||||
// Callback for EOSE (End Of Stored Events) - new signature
|
||||
static void on_admin_subscription_eose(cJSON** events, int event_count, void* user_data) {
|
||||
(void)events;
|
||||
(void)event_count;
|
||||
(void)user_data;
|
||||
app_log(LOG_INFO, "Received EOSE for admin command subscription");
|
||||
}
|
||||
|
||||
// Subscribe to admin commands (Kind 23458)
|
||||
static int subscribe_to_admin_commands(void) {
|
||||
if (!g_relay_state.pool) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
app_log(LOG_INFO, "Subscribing to Kind 23458 admin commands...");
|
||||
|
||||
// Create subscription filter for Kind 23458 events addressed to us
|
||||
cJSON* filter = cJSON_CreateObject();
|
||||
cJSON* kinds = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(23458));
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
|
||||
cJSON* p_tags = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(p_tags, cJSON_CreateString(g_blossom_pubkey));
|
||||
cJSON_AddItemToObject(filter, "#p", p_tags);
|
||||
|
||||
cJSON_AddNumberToObject(filter, "since", (double)time(NULL));
|
||||
|
||||
// Subscribe using pool with new API signature
|
||||
g_relay_state.admin_subscription = nostr_relay_pool_subscribe(
|
||||
g_relay_state.pool,
|
||||
(const char**)g_relay_state.relay_urls,
|
||||
g_relay_state.relay_count,
|
||||
filter,
|
||||
on_admin_command_event,
|
||||
on_admin_subscription_eose,
|
||||
NULL, // user_data
|
||||
0, // close_on_eose (keep subscription open)
|
||||
1, // enable_deduplication
|
||||
NOSTR_POOL_EOSE_FULL_SET, // result_mode
|
||||
30, // relay_timeout_seconds
|
||||
30 // eose_timeout_seconds
|
||||
);
|
||||
|
||||
cJSON_Delete(filter);
|
||||
|
||||
if (!g_relay_state.admin_subscription) {
|
||||
app_log(LOG_ERROR, "Failed to create admin command subscription");
|
||||
return -1;
|
||||
}
|
||||
|
||||
app_log(LOG_INFO, "Successfully subscribed to admin commands");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get current relay connection status
|
||||
char *relay_client_get_status(void) {
|
||||
if (!g_relay_state.pool) {
|
||||
return strdup("[]");
|
||||
}
|
||||
|
||||
cJSON *root = cJSON_CreateArray();
|
||||
|
||||
pthread_mutex_lock(&g_relay_state.state_mutex);
|
||||
for (int i = 0; i < g_relay_state.relay_count; i++) {
|
||||
cJSON *relay_obj = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(relay_obj, "url", g_relay_state.relay_urls[i]);
|
||||
|
||||
// Get status from pool
|
||||
nostr_pool_relay_status_t status = nostr_relay_pool_get_relay_status(
|
||||
g_relay_state.pool,
|
||||
g_relay_state.relay_urls[i]
|
||||
);
|
||||
|
||||
const char *state_str;
|
||||
switch (status) {
|
||||
case NOSTR_POOL_RELAY_CONNECTED: state_str = "connected"; break;
|
||||
case NOSTR_POOL_RELAY_CONNECTING: state_str = "connecting"; break;
|
||||
case NOSTR_POOL_RELAY_ERROR: state_str = "error"; break;
|
||||
default: state_str = "disconnected"; break;
|
||||
}
|
||||
cJSON_AddStringToObject(relay_obj, "state", state_str);
|
||||
|
||||
// Get statistics from pool
|
||||
const nostr_relay_stats_t* stats = nostr_relay_pool_get_relay_stats(
|
||||
g_relay_state.pool,
|
||||
g_relay_state.relay_urls[i]
|
||||
);
|
||||
|
||||
if (stats) {
|
||||
cJSON_AddNumberToObject(relay_obj, "events_received", stats->events_received);
|
||||
cJSON_AddNumberToObject(relay_obj, "events_published", stats->events_published);
|
||||
cJSON_AddNumberToObject(relay_obj, "connection_attempts", stats->connection_attempts);
|
||||
cJSON_AddNumberToObject(relay_obj, "connection_failures", stats->connection_failures);
|
||||
|
||||
if (stats->query_latency_avg > 0) {
|
||||
cJSON_AddNumberToObject(relay_obj, "query_latency_ms", stats->query_latency_avg);
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(root, relay_obj);
|
||||
}
|
||||
pthread_mutex_unlock(&g_relay_state.state_mutex);
|
||||
|
||||
char *json_str = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
|
||||
return json_str;
|
||||
}
|
||||
|
||||
// Force reconnection to all relays
|
||||
int relay_client_reconnect(void) {
|
||||
if (!g_relay_state.enabled || !g_relay_state.running || !g_relay_state.pool) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
app_log(LOG_INFO, "Forcing reconnection to all relays...");
|
||||
|
||||
// Remove and re-add all relays to force reconnection
|
||||
pthread_mutex_lock(&g_relay_state.state_mutex);
|
||||
for (int i = 0; i < g_relay_state.relay_count; i++) {
|
||||
nostr_relay_pool_remove_relay(g_relay_state.pool, g_relay_state.relay_urls[i]);
|
||||
nostr_relay_pool_add_relay(g_relay_state.pool, g_relay_state.relay_urls[i]);
|
||||
}
|
||||
pthread_mutex_unlock(&g_relay_state.state_mutex);
|
||||
|
||||
app_log(LOG_INFO, "Reconnection initiated for all relays");
|
||||
return 0;
|
||||
}
|
||||
78
src/relay_client.h
Normal file
78
src/relay_client.h
Normal file
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Ginxsom Relay Client - Nostr Relay Connection Manager
|
||||
*
|
||||
* This module enables Ginxsom to act as a Nostr client, connecting to relays
|
||||
* to publish events (Kind 0, Kind 10002) and subscribe to admin commands (Kind 23456).
|
||||
*/
|
||||
|
||||
#ifndef RELAY_CLIENT_H
|
||||
#define RELAY_CLIENT_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <time.h>
|
||||
|
||||
// Connection states for relay tracking
|
||||
typedef enum {
|
||||
RELAY_STATE_DISCONNECTED = 0,
|
||||
RELAY_STATE_CONNECTING = 1,
|
||||
RELAY_STATE_CONNECTED = 2,
|
||||
RELAY_STATE_ERROR = 3
|
||||
} relay_state_t;
|
||||
|
||||
// Relay connection info (in-memory only)
|
||||
typedef struct {
|
||||
char url[256];
|
||||
relay_state_t state;
|
||||
int reconnect_attempts;
|
||||
time_t last_connect_attempt;
|
||||
time_t connected_since;
|
||||
} relay_info_t;
|
||||
|
||||
// Initialize relay client system
|
||||
// Loads configuration from database and prepares for connections
|
||||
// Returns: 0 on success, -1 on error
|
||||
int relay_client_init(const char *db_path);
|
||||
|
||||
// Start relay connections
|
||||
// Connects to all relays specified in kind_10002_tags config
|
||||
// Publishes Kind 0 and Kind 10002 events after successful connection
|
||||
// Returns: 0 on success, -1 on error
|
||||
int relay_client_start(void);
|
||||
|
||||
// Stop relay connections and cleanup
|
||||
// Gracefully disconnects from all relays and stops background thread
|
||||
void relay_client_stop(void);
|
||||
|
||||
// Check if relay client is enabled
|
||||
// Returns: 1 if enabled, 0 if disabled
|
||||
int relay_client_is_enabled(void);
|
||||
|
||||
// Publish Kind 0 profile event to all connected relays
|
||||
// Uses kind_0_content from config database
|
||||
// Returns: 0 on success, -1 on error
|
||||
int relay_client_publish_kind0(void);
|
||||
|
||||
// Publish Kind 10002 relay list event to all connected relays
|
||||
// Uses kind_10002_tags from config database
|
||||
// Returns: 0 on success, -1 on error
|
||||
int relay_client_publish_kind10002(void);
|
||||
|
||||
// Send Kind 23457 admin response event
|
||||
// Encrypts content using NIP-44 and publishes to all connected relays
|
||||
// Parameters:
|
||||
// - recipient_pubkey: Admin's public key (recipient)
|
||||
// - response_content: JSON response content to encrypt
|
||||
// Returns: 0 on success, -1 on error
|
||||
int relay_client_send_admin_response(const char *recipient_pubkey, const char *response_content);
|
||||
|
||||
// Get current relay connection status
|
||||
// Returns JSON string with relay status (caller must free)
|
||||
// Format: [{"url": "wss://...", "state": "connected", "connected_since": 1234567890}, ...]
|
||||
char *relay_client_get_status(void);
|
||||
|
||||
// Force reconnection to all relays
|
||||
// Disconnects and reconnects to all configured relays
|
||||
// Returns: 0 on success, -1 on error
|
||||
int relay_client_reconnect(void);
|
||||
|
||||
#endif // RELAY_CLIENT_H
|
||||
@@ -529,7 +529,7 @@ int nostr_validate_unified_request(const nostr_unified_request_t *request,
|
||||
"VALIDATOR_DEBUG: STEP 10 FAILED - NIP-42 requires request_url and "
|
||||
"challenge (from event tags)\n");
|
||||
result->valid = 0;
|
||||
result->error_code = NOSTR_ERROR_NIP42_NOT_CONFIGURED;
|
||||
result->error_code = NOSTR_ERROR_NIP42_INVALID_CHALLENGE;
|
||||
strcpy(result->reason, "NIP-42 authentication requires request_url and challenge in event tags");
|
||||
cJSON_Delete(event);
|
||||
return NOSTR_SUCCESS;
|
||||
@@ -549,15 +549,12 @@ int nostr_validate_unified_request(const nostr_unified_request_t *request,
|
||||
|
||||
// Map specific NIP-42 error codes to detailed error messages
|
||||
switch (nip42_result) {
|
||||
case NOSTR_ERROR_NIP42_CHALLENGE_NOT_FOUND:
|
||||
strcpy(result->reason, "Challenge not found or has been used. Request a new challenge from /auth endpoint.");
|
||||
case NOSTR_ERROR_NIP42_INVALID_CHALLENGE:
|
||||
strcpy(result->reason, "Challenge not found or invalid. Request a new challenge from /auth endpoint.");
|
||||
break;
|
||||
case NOSTR_ERROR_NIP42_CHALLENGE_EXPIRED:
|
||||
strcpy(result->reason, "Challenge has expired. Request a new challenge from /auth endpoint.");
|
||||
break;
|
||||
case NOSTR_ERROR_NIP42_INVALID_CHALLENGE:
|
||||
strcpy(result->reason, "Invalid challenge format. Challenge must be a valid hex string.");
|
||||
break;
|
||||
case NOSTR_ERROR_NIP42_URL_MISMATCH:
|
||||
strcpy(result->reason, "Relay URL in auth event does not match server. Use 'ginxsom' as relay value.");
|
||||
break;
|
||||
@@ -576,12 +573,6 @@ int nostr_validate_unified_request(const nostr_unified_request_t *request,
|
||||
case NOSTR_ERROR_EVENT_INVALID_TAGS:
|
||||
strcpy(result->reason, "Required tags missing. Auth event must include 'relay' and 'expiration' tags.");
|
||||
break;
|
||||
case NOSTR_ERROR_NIP42_INVALID_RELAY_URL:
|
||||
strcpy(result->reason, "Invalid relay URL in tags. Use 'ginxsom' as the relay identifier.");
|
||||
break;
|
||||
case NOSTR_ERROR_NIP42_NOT_CONFIGURED:
|
||||
strcpy(result->reason, "NIP-42 authentication not properly configured on server.");
|
||||
break;
|
||||
default:
|
||||
snprintf(result->reason, sizeof(result->reason),
|
||||
"NIP-42 authentication failed (error code: %d). Check event structure and signature.",
|
||||
@@ -1907,7 +1898,7 @@ static int validate_challenge(const char *challenge_id) {
|
||||
}
|
||||
|
||||
validator_debug_log("NIP-42: Challenge not found\n");
|
||||
return NOSTR_ERROR_NIP42_CHALLENGE_NOT_FOUND;
|
||||
return NOSTR_ERROR_NIP42_INVALID_CHALLENGE;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
199
tests/23458_test.sh
Executable file
199
tests/23458_test.sh
Executable file
@@ -0,0 +1,199 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Simple test for Kind 23458 relay-based admin commands
|
||||
# Tests config_query command via Nostr relay subscription
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
TEST_KEYS_FILE=".test_keys"
|
||||
RELAY_URL="wss://relay.laantungir.net"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
||||
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
# Load test keys
|
||||
if [[ ! -f "$TEST_KEYS_FILE" ]]; then
|
||||
log_error "$TEST_KEYS_FILE not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source "$TEST_KEYS_FILE"
|
||||
|
||||
# Check dependencies
|
||||
for cmd in nak jq websocat; do
|
||||
if ! command -v $cmd &> /dev/null; then
|
||||
log_error "$cmd is not installed"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "=== Kind 23458 Admin Command Test ==="
|
||||
echo ""
|
||||
log_info "Configuration:"
|
||||
log_info " Admin Privkey: ${ADMIN_PRIVKEY:0:16}..."
|
||||
log_info " Server Pubkey: $SERVER_PUBKEY"
|
||||
log_info " Relay URL: $RELAY_URL"
|
||||
echo ""
|
||||
|
||||
# Test 1: Send config_query command
|
||||
log_info "Test: Sending config_query command"
|
||||
echo ""
|
||||
|
||||
# Encrypt command with NIP-44
|
||||
# Command format: ["config_query"]
|
||||
PLAINTEXT_COMMAND='["config_query"]'
|
||||
|
||||
log_info "Encrypting command with NIP-44..."
|
||||
ENCRYPTED_COMMAND=$(nak encrypt --sec "$ADMIN_PRIVKEY" -p "$SERVER_PUBKEY" "$PLAINTEXT_COMMAND")
|
||||
|
||||
if [[ -z "$ENCRYPTED_COMMAND" ]]; then
|
||||
log_error "Failed to encrypt command"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "Command encrypted"
|
||||
log_info "Encrypted content: ${ENCRYPTED_COMMAND:0:50}..."
|
||||
echo ""
|
||||
|
||||
log_info "Creating Kind 23458 event..."
|
||||
EVENT=$(nak event -k 23458 \
|
||||
-c "$ENCRYPTED_COMMAND" \
|
||||
--tag p="$SERVER_PUBKEY" \
|
||||
--sec "$ADMIN_PRIVKEY")
|
||||
|
||||
if [[ -z "$EVENT" ]]; then
|
||||
log_error "Failed to create event"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "Event created"
|
||||
echo "$EVENT" | jq .
|
||||
echo ""
|
||||
|
||||
# Step 1: Create pipes for bidirectional communication
|
||||
log_info "Step 1: Setting up websocat connection..."
|
||||
SINCE=$(date +%s)
|
||||
|
||||
# Create named pipes for input and output
|
||||
INPUT_PIPE=$(mktemp -u)
|
||||
OUTPUT_PIPE=$(mktemp -u)
|
||||
mkfifo "$INPUT_PIPE"
|
||||
mkfifo "$OUTPUT_PIPE"
|
||||
|
||||
# Start websocat in background with bidirectional communication
|
||||
(websocat "$RELAY_URL" < "$INPUT_PIPE" > "$OUTPUT_PIPE" 2>/dev/null) &
|
||||
WEBSOCAT_PID=$!
|
||||
|
||||
# Open pipes for writing and reading
|
||||
exec 3>"$INPUT_PIPE" # File descriptor 3 for writing
|
||||
exec 4<"$OUTPUT_PIPE" # File descriptor 4 for reading
|
||||
|
||||
# Give connection time to establish
|
||||
sleep 1
|
||||
log_success "WebSocket connection established"
|
||||
echo ""
|
||||
|
||||
# Step 2: Subscribe to Kind 23459 responses
|
||||
log_info "Step 2: Subscribing to Kind 23459 responses..."
|
||||
|
||||
# Create subscription filter
|
||||
SUBSCRIPTION_FILTER='["REQ","admin-response",{"kinds":[23459],"authors":["'$SERVER_PUBKEY'"],"#p":["'$ADMIN_PUBKEY'"],"since":'$SINCE'}]'
|
||||
|
||||
# Send subscription
|
||||
echo "$SUBSCRIPTION_FILTER" >&3
|
||||
sleep 1
|
||||
log_success "Subscription sent"
|
||||
echo ""
|
||||
|
||||
# Step 3: Publish the command event
|
||||
log_info "Step 3: Publishing Kind 23458 command event..."
|
||||
|
||||
# Create EVENT message
|
||||
EVENT_MSG='["EVENT",'$EVENT']'
|
||||
|
||||
# Send event
|
||||
echo "$EVENT_MSG" >&3
|
||||
sleep 1
|
||||
log_success "Event published"
|
||||
echo ""
|
||||
|
||||
# Step 4: Wait for response
|
||||
log_info "Step 4: Waiting for Kind 23459 response (timeout: 15s)..."
|
||||
|
||||
RESPONSE_RECEIVED=0
|
||||
TIMEOUT=15
|
||||
START_TIME=$(date +%s)
|
||||
|
||||
while [[ $(($(date +%s) - START_TIME)) -lt $TIMEOUT ]]; do
|
||||
if read -t 1 -r line <&4; then
|
||||
if [[ -n "$line" ]]; then
|
||||
# Parse the relay message
|
||||
MSG_TYPE=$(echo "$line" | jq -r '.[0] // empty' 2>/dev/null)
|
||||
|
||||
if [[ "$MSG_TYPE" == "EVENT" ]]; then
|
||||
# Extract the event (third element in array)
|
||||
EVENT_DATA=$(echo "$line" | jq '.[2]' 2>/dev/null)
|
||||
|
||||
if [[ -n "$EVENT_DATA" ]]; then
|
||||
log_success "Received Kind 23459 response!"
|
||||
echo "$EVENT_DATA" | jq .
|
||||
echo ""
|
||||
|
||||
# Extract and decrypt content
|
||||
ENCRYPTED_CONTENT=$(echo "$EVENT_DATA" | jq -r '.content // empty')
|
||||
SENDER_PUBKEY=$(echo "$EVENT_DATA" | jq -r '.pubkey // empty')
|
||||
|
||||
if [[ -n "$ENCRYPTED_CONTENT" ]] && [[ -n "$SENDER_PUBKEY" ]]; then
|
||||
log_info "Encrypted response: ${ENCRYPTED_CONTENT:0:50}..."
|
||||
log_info "Sender pubkey: $SENDER_PUBKEY"
|
||||
log_info "Decrypting response..."
|
||||
|
||||
# Try decryption with error output and timeout
|
||||
DECRYPT_OUTPUT=$(timeout 5s nak decrypt --sec "$ADMIN_PRIVKEY" -p "$SENDER_PUBKEY" "$ENCRYPTED_CONTENT" 2>&1)
|
||||
DECRYPT_EXIT=$?
|
||||
|
||||
if [[ $DECRYPT_EXIT -eq 0 ]] && [[ -n "$DECRYPT_OUTPUT" ]]; then
|
||||
log_success "Response decrypted successfully:"
|
||||
echo "$DECRYPT_OUTPUT" | jq . 2>/dev/null || echo "$DECRYPT_OUTPUT"
|
||||
RESPONSE_RECEIVED=1
|
||||
else
|
||||
log_error "Failed to decrypt response (exit code: $DECRYPT_EXIT)"
|
||||
if [[ -n "$DECRYPT_OUTPUT" ]]; then
|
||||
log_error "Decryption error: $DECRYPT_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
break
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Cleanup
|
||||
exec 3>&- # Close write pipe
|
||||
exec 4<&- # Close read pipe
|
||||
kill $WEBSOCAT_PID 2>/dev/null
|
||||
rm -f "$INPUT_PIPE" "$OUTPUT_PIPE"
|
||||
|
||||
if [[ $RESPONSE_RECEIVED -eq 0 ]]; then
|
||||
log_error "No response received within timeout period"
|
||||
log_info "This could mean:"
|
||||
log_info " 1. The server didn't receive the command"
|
||||
log_info " 2. The server received but didn't process the command"
|
||||
log_info " 3. The response was sent but not received by subscription"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
log_success "Test complete!"
|
||||
echo ""
|
||||
log_info "This test uses full NIP-44 encryption for both commands and responses."
|
||||
@@ -1,397 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Ginxsom WebSocket Admin Test Script
|
||||
# Tests Kind 23456/23457 admin command system over WebSocket with NIP-44 encryption
|
||||
#
|
||||
# Prerequisites:
|
||||
# - websocat: WebSocket client (https://github.com/vi/websocat)
|
||||
# - nak: Nostr Army Knife (https://github.com/fiatjaf/nak)
|
||||
# - jq: JSON processor
|
||||
# - Server running with test keys from .test_keys
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
WEBSOCKET_URL="wss://localhost:9443/admin" # Secure WebSocket via nginx HTTPS
|
||||
WEBSOCKET_HTTP_URL="ws://localhost:9001/admin" # Non-secure WebSocket via nginx HTTP
|
||||
WEBSOCKET_DIRECT_URL="ws://localhost:9442" # Direct connection to WebSocket server (port 9442)
|
||||
TEST_KEYS_FILE=".test_keys"
|
||||
TIMEOUT=10 # WebSocket connection timeout in seconds
|
||||
|
||||
# Load test keys
|
||||
if [[ ! -f "$TEST_KEYS_FILE" ]]; then
|
||||
echo "ERROR: $TEST_KEYS_FILE not found"
|
||||
echo "Run the server with --test-keys to generate test keys"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source "$TEST_KEYS_FILE"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Helper functions
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
log_debug() {
|
||||
echo -e "${CYAN}[DEBUG]${NC} $1"
|
||||
}
|
||||
|
||||
check_dependencies() {
|
||||
log_info "Checking dependencies..."
|
||||
|
||||
for cmd in websocat nak jq; do
|
||||
if ! command -v $cmd &> /dev/null; then
|
||||
log_error "$cmd is not installed"
|
||||
case $cmd in
|
||||
websocat)
|
||||
echo "Install from: https://github.com/vi/websocat"
|
||||
echo " cargo install websocat"
|
||||
;;
|
||||
nak)
|
||||
echo "Install from: https://github.com/fiatjaf/nak"
|
||||
echo " go install github.com/fiatjaf/nak@latest"
|
||||
;;
|
||||
jq)
|
||||
echo "Install jq for JSON processing"
|
||||
echo " apt-get install jq # Debian/Ubuntu"
|
||||
;;
|
||||
esac
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
log_success "All dependencies found"
|
||||
log_info " websocat: $(websocat --version 2>&1 | head -n1)"
|
||||
log_info " nak: $(nak --version 2>&1 | head -n1)"
|
||||
log_info " jq: $(jq --version 2>&1)"
|
||||
}
|
||||
|
||||
# Test basic WebSocket connection
|
||||
test_websocket_connection() {
|
||||
local url="$1"
|
||||
log_info "=== Testing WebSocket Connection ==="
|
||||
log_info "Connecting to: $url"
|
||||
|
||||
# For wss:// connections, add --insecure flag to skip certificate verification
|
||||
local websocat_opts=""
|
||||
if [[ "$url" == wss://* ]]; then
|
||||
websocat_opts="--insecure"
|
||||
log_debug "Using --insecure flag for self-signed certificate"
|
||||
fi
|
||||
|
||||
# Try to connect and send a ping
|
||||
local result=$(timeout $TIMEOUT websocat $websocat_opts -n1 "$url" <<< '{"test":"ping"}' 2>&1 || echo "TIMEOUT")
|
||||
|
||||
if [[ "$result" == "TIMEOUT" ]]; then
|
||||
log_error "Connection timeout after ${TIMEOUT}s"
|
||||
return 1
|
||||
elif [[ -z "$result" ]]; then
|
||||
log_warning "Connected but no response (this may be normal for WebSocket)"
|
||||
return 0
|
||||
else
|
||||
log_success "Connection established"
|
||||
log_debug "Response: $result"
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Create NIP-44 encrypted admin command event (Kind 23456)
|
||||
create_admin_command_event() {
|
||||
local command="$1"
|
||||
local expiration=$(($(date +%s) + 3600)) # 1 hour from now
|
||||
|
||||
log_info "Creating Kind 23456 admin command event..."
|
||||
log_info "Command: $command"
|
||||
|
||||
# Content is a JSON array of commands
|
||||
local content="[\"$command\"]"
|
||||
|
||||
# Create event with nak
|
||||
# Kind 23456 = admin command
|
||||
# Tags: p = server pubkey, expiration
|
||||
local event=$(nak event -k 23456 \
|
||||
-c "$content" \
|
||||
--tag p="$SERVER_PUBKEY" \
|
||||
--tag expiration="$expiration" \
|
||||
--sec "$ADMIN_PRIVKEY" 2>&1)
|
||||
|
||||
if [[ $? -ne 0 ]]; then
|
||||
log_error "Failed to create event with nak"
|
||||
log_error "$event"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$event"
|
||||
}
|
||||
|
||||
# Send admin command via WebSocket and wait for response
|
||||
send_websocket_admin_command() {
|
||||
local command="$1"
|
||||
local url="$2"
|
||||
|
||||
log_info "=== Testing Admin Command via WebSocket: $command ==="
|
||||
|
||||
# Create Kind 23456 event
|
||||
local event=$(create_admin_command_event "$command")
|
||||
|
||||
if [[ -z "$event" ]]; then
|
||||
log_error "Failed to create admin event"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_success "Event created successfully"
|
||||
log_debug "Event JSON:"
|
||||
echo "$event" | jq -C . 2>/dev/null || echo "$event"
|
||||
echo ""
|
||||
|
||||
# Send to WebSocket server and wait for response
|
||||
log_info "Sending to WebSocket: $url"
|
||||
log_info "Waiting for Kind 23457 response (timeout: ${TIMEOUT}s)..."
|
||||
|
||||
# For wss:// connections, add --insecure flag to skip certificate verification
|
||||
local websocat_opts=""
|
||||
if [[ "$url" == wss://* ]]; then
|
||||
websocat_opts="--insecure"
|
||||
log_debug "Using --insecure flag for self-signed certificate"
|
||||
fi
|
||||
|
||||
# Use websocat to send event and receive response
|
||||
local response=$(timeout $TIMEOUT websocat $websocat_opts -n1 "$url" <<< "$event" 2>&1)
|
||||
local exit_code=$?
|
||||
|
||||
echo ""
|
||||
if [[ $exit_code -eq 124 ]]; then
|
||||
log_error "Timeout waiting for response after ${TIMEOUT}s"
|
||||
return 1
|
||||
elif [[ $exit_code -ne 0 ]]; then
|
||||
log_error "WebSocket connection failed (exit code: $exit_code)"
|
||||
log_error "$response"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -z "$response" ]]; then
|
||||
log_warning "No response received (connection may have closed)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_success "Response received"
|
||||
log_debug "Raw response:"
|
||||
echo "$response"
|
||||
echo ""
|
||||
|
||||
# Try to parse as JSON
|
||||
if echo "$response" | jq . &>/dev/null; then
|
||||
log_success "Valid JSON response"
|
||||
|
||||
# Check if it's a Kind 23457 event
|
||||
local kind=$(echo "$response" | jq -r '.kind // empty' 2>/dev/null)
|
||||
if [[ "$kind" == "23457" ]]; then
|
||||
log_success "Received Kind 23457 response event ✓"
|
||||
|
||||
# Extract and display response details
|
||||
local response_id=$(echo "$response" | jq -r '.id // empty')
|
||||
local response_pubkey=$(echo "$response" | jq -r '.pubkey // empty')
|
||||
local response_content=$(echo "$response" | jq -r '.content // empty')
|
||||
local response_sig=$(echo "$response" | jq -r '.sig // empty')
|
||||
|
||||
echo ""
|
||||
log_info "Response Event Details:"
|
||||
log_info " ID: $response_id"
|
||||
log_info " Pubkey: $response_pubkey"
|
||||
log_info " Content: $response_content"
|
||||
log_info " Sig: ${response_sig:0:32}..."
|
||||
|
||||
# Check if content is encrypted (NIP-44)
|
||||
if [[ ${#response_content} -gt 50 ]]; then
|
||||
log_info " Content appears to be NIP-44 encrypted"
|
||||
log_warning " Decryption not yet implemented in test script"
|
||||
else
|
||||
log_info " Content (plaintext): $response_content"
|
||||
fi
|
||||
|
||||
# Verify signature
|
||||
log_info "Verifying event signature..."
|
||||
if echo "$response" | nak verify 2>&1 | grep -q "signature is valid"; then
|
||||
log_success "Event signature is valid ✓"
|
||||
else
|
||||
log_error "Event signature verification failed"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
log_warning "Response is not Kind 23457 (got kind: $kind)"
|
||||
fi
|
||||
|
||||
# Pretty print the full response
|
||||
echo ""
|
||||
log_info "Full Response Event:"
|
||||
echo "$response" | jq -C .
|
||||
else
|
||||
log_warning "Response is not valid JSON"
|
||||
log_debug "Raw response: $response"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
return 0
|
||||
}
|
||||
|
||||
# Test config_query command
|
||||
test_config_query() {
|
||||
log_info "=== Testing config_query Command ==="
|
||||
send_websocket_admin_command "config_query" "$WEBSOCKET_URL"
|
||||
}
|
||||
|
||||
# Test with HTTP WebSocket connection
|
||||
test_http_connection() {
|
||||
log_info "=== Testing HTTP WebSocket Connection ==="
|
||||
log_info "Connecting via HTTP (port 9001)"
|
||||
send_websocket_admin_command "config_query" "$WEBSOCKET_HTTP_URL"
|
||||
}
|
||||
|
||||
# Test with direct WebSocket connection (bypassing nginx)
|
||||
test_direct_connection() {
|
||||
log_info "=== Testing Direct WebSocket Connection ==="
|
||||
log_info "Connecting directly to WebSocket server (port 9442)"
|
||||
send_websocket_admin_command "config_query" "$WEBSOCKET_DIRECT_URL"
|
||||
}
|
||||
|
||||
# Test invalid command
|
||||
test_invalid_command() {
|
||||
log_info "=== Testing Invalid Command ==="
|
||||
send_websocket_admin_command "invalid_command_xyz" "$WEBSOCKET_URL" || log_warning "Expected failure for invalid command"
|
||||
}
|
||||
|
||||
# Test connection persistence
|
||||
test_connection_persistence() {
|
||||
log_info "=== Testing Connection Persistence ==="
|
||||
log_info "Sending multiple commands over same connection..."
|
||||
|
||||
# Create two events
|
||||
local event1=$(create_admin_command_event "config_query")
|
||||
local event2=$(create_admin_command_event "config_query")
|
||||
|
||||
if [[ -z "$event1" ]] || [[ -z "$event2" ]]; then
|
||||
log_error "Failed to create events"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# For wss:// connections, add --insecure flag
|
||||
local websocat_opts=""
|
||||
if [[ "$WEBSOCKET_URL" == wss://* ]]; then
|
||||
websocat_opts="--insecure"
|
||||
fi
|
||||
|
||||
# Send both events and collect responses
|
||||
log_info "Sending two events sequentially..."
|
||||
local responses=$(timeout $((TIMEOUT * 2)) websocat $websocat_opts -n2 "$WEBSOCKET_URL" <<EOF
|
||||
$event1
|
||||
$event2
|
||||
EOF
|
||||
)
|
||||
|
||||
if [[ $? -eq 0 ]]; then
|
||||
log_success "Received responses for both events"
|
||||
echo "$responses" | while IFS= read -r line; do
|
||||
if [[ -n "$line" ]]; then
|
||||
echo "$line" | jq -C . 2>/dev/null || echo "$line"
|
||||
fi
|
||||
done
|
||||
else
|
||||
log_warning "Connection persistence test inconclusive"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
main() {
|
||||
echo "=========================================="
|
||||
echo " Ginxsom WebSocket Admin Test Suite"
|
||||
echo " Kind 23456/23457 over WebSocket"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
log_info "Test Configuration:"
|
||||
log_info " Admin Privkey: ${ADMIN_PRIVKEY:0:16}...${ADMIN_PRIVKEY: -16}"
|
||||
log_info " Admin Pubkey: $ADMIN_PUBKEY"
|
||||
log_info " Server Pubkey: $SERVER_PUBKEY"
|
||||
log_info " HTTPS URL: $WEBSOCKET_URL"
|
||||
log_info " HTTP URL: $WEBSOCKET_HTTP_URL"
|
||||
log_info " Direct URL: $WEBSOCKET_DIRECT_URL"
|
||||
log_info " Timeout: ${TIMEOUT}s"
|
||||
echo ""
|
||||
|
||||
check_dependencies
|
||||
echo ""
|
||||
|
||||
# Test basic WebSocket connectivity
|
||||
if ! test_websocket_connection "$WEBSOCKET_URL"; then
|
||||
log_error "Basic WebSocket connection failed"
|
||||
log_info "Trying direct connection to port 9442..."
|
||||
if ! test_websocket_connection "$WEBSOCKET_DIRECT_URL"; then
|
||||
log_error "Direct connection also failed"
|
||||
log_error "Make sure the server is running with WebSocket admin enabled"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Test admin commands via HTTPS
|
||||
test_config_query
|
||||
echo ""
|
||||
|
||||
# Test via HTTP
|
||||
test_http_connection
|
||||
echo ""
|
||||
|
||||
# Test direct connection (bypassing nginx)
|
||||
test_direct_connection
|
||||
echo ""
|
||||
|
||||
# Test invalid command
|
||||
test_invalid_command
|
||||
echo ""
|
||||
|
||||
# Test connection persistence
|
||||
test_connection_persistence
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
log_success "WebSocket admin testing complete!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
log_info "Summary:"
|
||||
log_info " ✓ WebSocket connection established"
|
||||
log_info " ✓ Kind 23456 events sent"
|
||||
log_info " ✓ Kind 23457 responses received"
|
||||
log_info " ✓ Event signatures verified"
|
||||
echo ""
|
||||
|
||||
log_warning "NOTE: NIP-44 encryption/decryption not yet implemented in test script"
|
||||
log_warning "Events use plaintext command arrays for initial testing"
|
||||
log_warning "Production implementation uses full NIP-44 encryption"
|
||||
}
|
||||
|
||||
# Allow sourcing for individual function testing
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
main "$@"
|
||||
fi
|
||||
Reference in New Issue
Block a user