diff --git a/.test_keys b/.test_keys index 5c0d7fd..ccbe9a6 100644 --- a/.test_keys +++ b/.test_keys @@ -1,4 +1,4 @@ ADMIN_PRIVKEY='22cc83aa57928a2800234c939240c9a6f0f44a33ea3838a860ed38930b195afd' ADMIN_PUBKEY='8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e' SERVER_PRIVKEY='c4e0d2ed7d36277d6698650f68a6e9199f91f3abb476a67f07303e81309c48f1' -SERVER_PUBKEY='ebe82fbff0ff79b2973892eb285cafc767863e434f894838a548580266b70254' +SERVER_PUBKEY='52e366edfa4e9cc6a6d4653828e51ccf828a2f5a05227d7a768f33b5a198681a' diff --git a/Makefile b/Makefile index d248ea8..036f5df 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ # Ginxsom Blossom Server Makefile CC = gcc -CFLAGS = -Wall -Wextra -std=c99 -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 +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) SRCDIR = src BUILDDIR = build TARGET = $(BUILDDIR)/ginxsom-fcgi diff --git a/build/admin_websocket.o b/build/admin_websocket.o index ee5cf97..b51f9ad 100644 Binary files a/build/admin_websocket.o and b/build/admin_websocket.o differ diff --git a/build/ginxsom-fcgi b/build/ginxsom-fcgi index 20d6c27..cf9a49c 100755 Binary files a/build/ginxsom-fcgi and b/build/ginxsom-fcgi differ diff --git a/build/main.o b/build/main.o index 2d49814..156f4aa 100644 Binary files a/build/main.o and b/build/main.o differ diff --git a/config/local-nginx.conf b/config/local-nginx.conf index 57db5a5..b5fbd32 100644 --- a/config/local-nginx.conf +++ b/config/local-nginx.conf @@ -219,6 +219,25 @@ 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)$) { @@ -570,6 +589,25 @@ 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)$) { diff --git a/db/52e366edfa4e9cc6a6d4653828e51ccf828a2f5a05227d7a768f33b5a198681a.db b/db/52e366edfa4e9cc6a6d4653828e51ccf828a2f5a05227d7a768f33b5a198681a.db index 97a2832..51500f6 100644 Binary files a/db/52e366edfa4e9cc6a6d4653828e51ccf828a2f5a05227d7a768f33b5a198681a.db and b/db/52e366edfa4e9cc6a6d4653828e51ccf828a2f5a05227d7a768f33b5a198681a.db differ diff --git a/ginxsom.code-workspace b/ginxsom.code-workspace new file mode 100644 index 0000000..876a149 --- /dev/null +++ b/ginxsom.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": {} +} \ No newline at end of file diff --git a/restart-all.sh b/restart-all.sh index 60964c1..65864f1 100755 --- a/restart-all.sh +++ b/restart-all.sh @@ -246,24 +246,37 @@ else echo -e "${YELLOW}Starting FastCGI in production mode - will generate new keys and create database${NC}" fi -# Start FastCGI application with proper logging (daemonized but with redirected streams) +# Start FastCGI application with proper logging echo "FastCGI starting at $(date)" >> logs/app/stderr.log -spawn-fcgi -s "$SOCKET_PATH" -M 666 -u "$USER" -g "$USER" -P "$PID_FILE" -- "$FCGI_BINARY" $FCGI_ARGS 1>>logs/app/stdout.log 2>>logs/app/stderr.log -if [ $? -eq 0 ] && [ -f "$PID_FILE" ]; then - PID=$(cat "$PID_FILE") +# Use nohup with spawn-fcgi -n to keep process running with redirected output +# The key is: nohup prevents HUP signal, -n prevents daemonization (keeps stderr connected) +nohup spawn-fcgi -n -s "$SOCKET_PATH" -M 666 -u "$USER" -g "$USER" -- "$FCGI_BINARY" $FCGI_ARGS >>logs/app/stdout.log 2>>logs/app/stderr.log "$PID_FILE" + +# Give it a moment to start +sleep 1 + +if check_process "$FCGI_PID"; then echo -e "${GREEN}FastCGI application started successfully${NC}" - echo "PID: $PID" - - # Verify it's actually running - if check_process "$PID"; then - echo -e "${GREEN}Process confirmed running${NC}" - else - echo -e "${RED}Warning: Process may have crashed immediately${NC}" - exit 1 - fi + echo "PID: $FCGI_PID" + echo -e "${GREEN}Process confirmed running${NC}" else echo -e "${RED}Failed to start FastCGI application${NC}" + echo -e "${RED}Process may have crashed immediately${NC}" exit 1 fi @@ -334,4 +347,8 @@ echo -e "${YELLOW}To stop all processes, run: nginx -p . -c $NGINX_CONFIG -s sto echo -e "${YELLOW}To monitor logs, check: logs/nginx/error.log, logs/nginx/access.log, logs/app/stderr.log, logs/app/stdout.log${NC}" echo -e "\n${YELLOW}Server is available at:${NC}" echo -e " ${GREEN}HTTP:${NC} http://localhost:9001" -echo -e " ${GREEN}HTTPS:${NC} https://localhost:9443" \ No newline at end of file +echo -e " ${GREEN}HTTPS:${NC} https://localhost:9443" +echo -e "\n${YELLOW}Admin WebSocket endpoint:${NC}" +echo -e " ${GREEN}WSS:${NC} wss://localhost:9443/admin (via nginx proxy)" +echo -e " ${GREEN}WS:${NC} ws://localhost:9001/admin (via nginx proxy)" +echo -e " ${GREEN}Direct:${NC} ws://localhost:9442 (direct connection)" \ No newline at end of file diff --git a/src/admin_websocket.c b/src/admin_websocket.c index 1c37248..1b36e0b 100644 --- a/src/admin_websocket.c +++ b/src/admin_websocket.c @@ -1,163 +1,541 @@ /* - * Ginxsom Admin WebSocket Module + * Ginxsom Admin WebSocket Server * Handles WebSocket connections for Kind 23456/23457 admin commands - * Based on c-relay's WebSocket implementation + * Based on c-relay's WebSocket implementation using libwebsockets */ -#include "ginxsom.h" +#include #include #include #include +#include #include #include +#include +#include "ginxsom.h" -// Forward declarations from admin_auth.c -int process_admin_command(cJSON *event, char ***command_array_out, int *command_count_out, char **admin_pubkey_out); -void free_command_array(char **command_array, int command_count); -int create_admin_response(const char *response_json, const char *admin_pubkey, const char *original_event_id, cJSON **response_event_out); +// 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); -// Forward declarations from admin_handlers.c (to be created) -int execute_admin_command(char **command_array, int command_count, const char *admin_pubkey, char **response_json_out); +// 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; +}; -// Handle WebSocket admin command endpoint (/api/admin) -void handle_admin_websocket_request(void) { - // For now, this is a placeholder for WebSocket implementation - // In a full implementation, this would: - // 1. Upgrade HTTP connection to WebSocket - // 2. Handle WebSocket frames - // 3. Process Kind 23456 events - // 4. Send Kind 23457 responses +// Global WebSocket context +static struct lws_context *ws_context = NULL; +static volatile int force_exit = 0; - printf("Status: 501 Not Implemented\r\n"); - printf("Content-Type: application/json\r\n\r\n"); - printf("{\n"); - printf(" \"error\": \"websocket_not_implemented\",\n"); - printf(" \"message\": \"WebSocket admin endpoint not yet implemented\",\n"); - printf(" \"note\": \"Use HTTP POST to /api/admin for now\"\n"); - printf("}\n"); +// 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; } -// Handle HTTP POST admin command endpoint (/api/admin) -void handle_admin_command_post_request(void) { - // Read the request body (should contain Kind 23456 event JSON) - const char *content_length_str = getenv("CONTENT_LENGTH"); - if (!content_length_str) { - printf("Status: 400 Bad Request\r\n"); - printf("Content-Type: application/json\r\n\r\n"); - printf("{\n"); - printf(" \"error\": \"missing_content_length\",\n"); - printf(" \"message\": \"Content-Length header required\"\n"); - printf("}\n"); - return; - } - - long content_length = atol(content_length_str); - if (content_length <= 0 || content_length > 1024 * 1024) { // 1MB limit - printf("Status: 400 Bad Request\r\n"); - printf("Content-Type: application/json\r\n\r\n"); - printf("{\n"); - printf(" \"error\": \"invalid_content_length\",\n"); - printf(" \"message\": \"Content-Length must be between 1 and 1MB\"\n"); - printf("}\n"); - return; - } - - // Read the request body - char *request_body = malloc(content_length + 1); - if (!request_body) { - printf("Status: 500 Internal Server Error\r\n"); - printf("Content-Type: application/json\r\n\r\n"); - printf("{\n"); - printf(" \"error\": \"memory_allocation_failed\",\n"); - printf(" \"message\": \"Failed to allocate memory for request body\"\n"); - printf("}\n"); - return; - } - - size_t bytes_read = fread(request_body, 1, content_length, stdin); - if (bytes_read != (size_t)content_length) { - free(request_body); - printf("Status: 400 Bad Request\r\n"); - printf("Content-Type: application/json\r\n\r\n"); - printf("{\n"); - printf(" \"error\": \"incomplete_request_body\",\n"); - printf(" \"message\": \"Failed to read complete request body\"\n"); - printf("}\n"); - return; - } - - request_body[content_length] = '\0'; - - // Parse the JSON event - cJSON *event = cJSON_Parse(request_body); - free(request_body); +/** + * 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) { - printf("Status: 400 Bad Request\r\n"); - printf("Content-Type: application/json\r\n\r\n"); - printf("{\n"); - printf(" \"error\": \"invalid_json\",\n"); - printf(" \"message\": \"Request body is not valid JSON\"\n"); - printf("}\n"); - return; + fprintf(stderr, "[WebSocket] Invalid JSON\n"); + fflush(stderr); + return -1; } - - // Process the admin command - char **command_array = NULL; - int command_count = 0; - char *admin_pubkey = NULL; - - int result = process_admin_command(event, &command_array, &command_count, &admin_pubkey); + + // 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) { - printf("Status: 400 Bad Request\r\n"); - printf("Content-Type: application/json\r\n\r\n"); - printf("{\n"); - printf(" \"error\": \"invalid_admin_command\",\n"); - printf(" \"message\": \"Failed to process admin command\"\n"); - printf("}\n"); - return; + 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; +} - // Execute the command - char *response_json = NULL; - int exec_result = execute_admin_command(command_array, command_count, admin_pubkey, &response_json); - free_command_array(command_array, command_count); - free(admin_pubkey); - - if (exec_result != 0) { - printf("Status: 500 Internal Server Error\r\n"); - printf("Content-Type: application/json\r\n\r\n"); - printf("{\n"); - printf(" \"error\": \"command_execution_failed\",\n"); - printf(" \"message\": \"Failed to execute admin command\"\n"); - printf("}\n"); - return; - } - - // Create the response event (Kind 23457) - cJSON *response_event = NULL; - int create_result = create_admin_response(response_json, admin_pubkey, NULL, &response_event); - free(response_json); - - if (create_result != 0) { - printf("Status: 500 Internal Server Error\r\n"); - printf("Content-Type: application/json\r\n\r\n"); - printf("{\n"); - printf(" \"error\": \"response_creation_failed\",\n"); - printf(" \"message\": \"Failed to create admin response\"\n"); - printf("}\n"); - return; - } - - // Return the response event as JSON - char *response_json_str = cJSON_Print(response_event); - cJSON_Delete(response_event); - - printf("Status: 200 OK\r\n"); - printf("Content-Type: application/json\r\n\r\n"); - printf("%s\n", response_json_str); - - free(response_json_str); +/** + * Stop admin WebSocket server + */ +void stop_admin_websocket_server(void) { + force_exit = 1; } \ No newline at end of file diff --git a/src/ginxsom.h b/src/ginxsom.h index 2eb5261..e280d8f 100644 --- a/src/ginxsom.h +++ b/src/ginxsom.h @@ -10,8 +10,8 @@ // Version information (auto-updated by build system) #define VERSION_MAJOR 0 #define VERSION_MINOR 1 -#define VERSION_PATCH 10 -#define VERSION "v0.1.10" +#define VERSION_PATCH 11 +#define VERSION "v0.1.11" #include #include @@ -283,6 +283,10 @@ 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 diff --git a/src/main.c b/src/main.c index 5af0b1b..bc74fc5 100644 --- a/src/main.c +++ b/src/main.c @@ -1830,8 +1830,18 @@ 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"); - fflush(stderr); // Parse command line arguments int use_test_keys = 0; @@ -1934,6 +1944,9 @@ 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); + } else { + fprintf(stderr, "TEST MODE: Failed to parse SERVER_PRIVKEY (length: %ld)\n", end ? (long)(end - start) : -1L); } } } @@ -2022,6 +2035,7 @@ int main(int argc, char *argv[]) { if (db_path_specified) { fprintf(stderr, "\n=== SCENARIO 5: DATABASE + KEYS (VALIDATION) ===\n"); strncpy(g_db_path, specified_db_path, sizeof(g_db_path) - 1); + g_db_path[sizeof(g_db_path) - 1] = '\0'; // Check if database exists struct stat st; @@ -2117,6 +2131,7 @@ int main(int argc, char *argv[]) { else if (db_path_specified) { fprintf(stderr, "\n=== SCENARIO 2: DATABASE SPECIFIED ===\n"); strncpy(g_db_path, specified_db_path, sizeof(g_db_path) - 1); + g_db_path[sizeof(g_db_path) - 1] = '\0'; // Check if database exists struct stat st; @@ -2205,6 +2220,32 @@ if (!config_loaded /* && !initialize_server_config() */) { "STARTUP: Request validator system initialized successfully\r\n"); fflush(stderr); + // 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); + } + sqlite3_close(db); + } ///////////////////////////////////////////////////////////////////// // THIS IS WHERE THE REQUESTS ENTER THE FastCGI diff --git a/tests/websocket_admin_test.sh b/tests/websocket_admin_test.sh new file mode 100755 index 0000000..f248598 --- /dev/null +++ b/tests/websocket_admin_test.sh @@ -0,0 +1,397 @@ +#!/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" </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 \ No newline at end of file