Compare commits

..

6 Commits

Author SHA1 Message Date
Your Name
6c10713e18 v0.2.1 - Nip-40 implemented 2025-09-05 14:45:32 -04:00
Your Name
b810982a17 v0.2.0 - Nip13 implemented 2025-09-05 14:14:15 -04:00
Your Name
23c95fd2ea v0.1.1 - -release 2025-09-05 13:00:42 -04:00
Your Name
e96957f91b v0.1.0 - New minor version 2025-09-05 11:32:31 -04:00
Your Name
de3e7c75a5 v0.2.0 - New minor version 2025-09-05 11:27:32 -04:00
Your Name
646adac981 v0.1.0 - New minor version 2025-09-05 11:26:08 -04:00
18 changed files with 2797 additions and 173 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
nostr_core_lib/
nips/
build/

122
Makefile
View File

@@ -5,27 +5,108 @@ CFLAGS = -Wall -Wextra -std=c99 -g -O2
INCLUDES = -I. -Inostr_core_lib -Inostr_core_lib/nostr_core -Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket
LIBS = -lsqlite3 -lwebsockets -lz -ldl -lpthread -lm -L/usr/local/lib -lsecp256k1 -lssl -lcrypto -L/usr/local/lib -lcurl
# Build directory
BUILD_DIR = build
# Source files
MAIN_SRC = src/main.c
NOSTR_CORE_LIB = nostr_core_lib/libnostr_core_x64.a
# Target binary
TARGET = src/main
# Architecture detection
ARCH = $(shell uname -m)
ifeq ($(ARCH),x86_64)
TARGET = $(BUILD_DIR)/c_relay_x86
else ifeq ($(ARCH),aarch64)
TARGET = $(BUILD_DIR)/c_relay_arm64
else ifeq ($(ARCH),arm64)
TARGET = $(BUILD_DIR)/c_relay_arm64
else
TARGET = $(BUILD_DIR)/c_relay_$(ARCH)
endif
# Default target
all: $(TARGET)
# Create build directory
$(BUILD_DIR):
mkdir -p $(BUILD_DIR)
# Check if nostr_core_lib is built
$(NOSTR_CORE_LIB):
@echo "Building nostr_core_lib..."
cd nostr_core_lib && ./build.sh
# Build the relay
$(TARGET): $(MAIN_SRC) $(NOSTR_CORE_LIB)
@echo "Compiling C-Relay..."
$(TARGET): $(BUILD_DIR) $(MAIN_SRC) $(NOSTR_CORE_LIB)
@echo "Compiling C-Relay for architecture: $(ARCH)"
$(CC) $(CFLAGS) $(INCLUDES) $(MAIN_SRC) -o $(TARGET) $(NOSTR_CORE_LIB) $(LIBS)
@echo "Build complete: $(TARGET)"
# Build for specific architectures
x86: $(BUILD_DIR) $(MAIN_SRC) $(NOSTR_CORE_LIB)
@echo "Building C-Relay for x86_64..."
$(CC) $(CFLAGS) $(INCLUDES) $(MAIN_SRC) -o $(BUILD_DIR)/c_relay_x86 $(NOSTR_CORE_LIB) $(LIBS)
@echo "Build complete: $(BUILD_DIR)/c_relay_x86"
arm64: $(BUILD_DIR) $(MAIN_SRC) $(NOSTR_CORE_LIB)
@echo "Cross-compiling C-Relay for ARM64..."
@if ! command -v aarch64-linux-gnu-gcc >/dev/null 2>&1; then \
echo "ERROR: ARM64 cross-compiler not found."; \
echo "Install with: make install-cross-tools"; \
echo "Or install manually: sudo apt install gcc-aarch64-linux-gnu"; \
exit 1; \
fi
@echo "Checking for ARM64 development libraries..."
@if ! dpkg -l | grep -q "libssl-dev:arm64\|libsqlite3-dev:arm64"; then \
echo "ERROR: ARM64 libraries not found. Cross-compilation requires ARM64 versions of:"; \
echo " - libssl-dev:arm64"; \
echo " - libsqlite3-dev:arm64"; \
echo " - libwebsockets-dev:arm64"; \
echo " - libsecp256k1-dev:arm64"; \
echo " - zlib1g-dev:arm64"; \
echo " - libcurl4-openssl-dev:arm64"; \
echo ""; \
echo "Install ARM64 libraries with: make install-arm64-deps"; \
echo "Or use Docker for cross-platform builds."; \
exit 1; \
fi
@echo "Using aarch64-linux-gnu-gcc with ARM64 libraries..."
PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig \
aarch64-linux-gnu-gcc $(CFLAGS) $(INCLUDES) $(MAIN_SRC) -o $(BUILD_DIR)/c_relay_arm64 $(NOSTR_CORE_LIB) \
-L/usr/lib/aarch64-linux-gnu $(LIBS)
@echo "Build complete: $(BUILD_DIR)/c_relay_arm64"
# Install ARM64 cross-compilation dependencies
install-arm64-deps:
@echo "Installing ARM64 cross-compilation dependencies..."
@echo "This requires adding ARM64 architecture and installing cross-libraries..."
sudo dpkg --add-architecture arm64
sudo apt update
sudo apt install -y \
gcc-aarch64-linux-gnu \
libc6-dev-arm64-cross \
libssl-dev:arm64 \
libsqlite3-dev:arm64 \
zlib1g-dev:arm64 \
libcurl4-openssl-dev:arm64
@echo "Note: libwebsockets-dev:arm64 and libsecp256k1-dev:arm64 may need manual building"
# Install cross-compilation tools
install-cross-tools:
@echo "Installing cross-compilation tools..."
sudo apt update
sudo apt install -y gcc-aarch64-linux-gnu libc6-dev-arm64-cross
# Check what architectures we can actually build
check-toolchain:
@echo "Checking available toolchains:"
@echo "Native compiler: $(shell $(CC) --version | head -1)"
@if command -v aarch64-linux-gnu-gcc >/dev/null 2>&1; then \
echo "ARM64 cross-compiler: $(shell aarch64-linux-gnu-gcc --version | head -1)"; \
else \
echo "ARM64 cross-compiler: NOT INSTALLED (install with 'make install-cross-tools')"; \
fi
# Run tests
test: $(TARGET)
@echo "Running tests..."
@@ -38,7 +119,7 @@ init-db:
# Clean build artifacts
clean:
rm -f $(TARGET)
rm -rf $(BUILD_DIR)
@echo "Clean complete"
# Clean everything including nostr_core_lib
@@ -56,17 +137,26 @@ help:
@echo "C-Relay Build System"
@echo ""
@echo "Targets:"
@echo " all Build the relay (default)"
@echo " test Build and run tests"
@echo " init-db Initialize the database"
@echo " clean Clean build artifacts"
@echo " clean-all Clean everything including dependencies"
@echo " install-deps Install system dependencies"
@echo " help Show this help"
@echo " all Build the relay for current architecture (default)"
@echo " x86 Build specifically for x86_64"
@echo " arm64 Build for ARM64 (requires cross-compilation setup)"
@echo " test Build and run tests"
@echo " init-db Initialize the database"
@echo " clean Clean build artifacts"
@echo " clean-all Clean everything including dependencies"
@echo " install-deps Install system dependencies"
@echo " install-cross-tools Install basic ARM64 cross-compiler"
@echo " install-arm64-deps Install ARM64 cross-compilation libraries"
@echo " check-toolchain Check available compilers"
@echo " help Show this help"
@echo ""
@echo "Usage:"
@echo " make # Build the relay"
@echo " make test # Run tests"
@echo " make init-db # Set up database"
@echo " make # Build the relay for current arch"
@echo " make x86 # Build for x86_64"
@echo " make arm64 # Build for ARM64 (fails if cross-compilation not set up)"
@echo " make install-arm64-deps # Install full ARM64 cross-compilation setup"
@echo " make check-toolchain # Check what compilers are available"
@echo " make test # Run tests"
@echo " make init-db # Set up database"
.PHONY: all test init-db clean clean-all install-deps help
.PHONY: all x86 arm64 test init-db clean clean-all install-deps install-cross-tools install-arm64-deps check-toolchain help

View File

@@ -10,20 +10,13 @@ Do NOT modify the formatting, add emojis, or change the text. Keep the simple fo
- [x] NIP-01: Basic protocol flow implementation
- [x] NIP-09: Event deletion
- [ ] NIP-11: Relay information document
- [ ] NIP-12: Generic tag queries
- [ ] NIP-13: Proof of Work
- [x] NIP-11: Relay information document
- [x] NIP-13: Proof of Work
- [x] NIP-15: End of Stored Events Notice
- [ ] NIP-16: Event Treatment
- [x] NIP-20: Command Results
- [ ] NIP-22: Event `created_at` Limits
- [ ] NIP-25: Reactions
- [ ] NIP-26: Delegated Event Signing
- [ ] NIP-28: Public Chat
- [ ] NIP-33: Parameterized Replaceable Events
- [ ] NIP-40: Expiration Timestamp
- [x] NIP-33: Parameterized Replaceable Events
- [x] NIP-40: Expiration Timestamp
- [ ] NIP-42: Authentication of clients to relays
- [ ] NIP-45: Counting results. [experimental](#count)
- [ ] NIP-50: Keywords filter. [experimental](#search)
- [ ] NIP-45: Counting results.
- [ ] NIP-50: Keywords filter.
- [ ] NIP-70: Protected Events

387
admin_spec.md Normal file
View File

@@ -0,0 +1,387 @@
# Ginxsom Admin System - Comprehensive Specification
## Overview
The Ginxsom admin system provides both programmatic (API-based) and interactive (web-based) administration capabilities for the Ginxsom Blossom server. The system is designed around Nostr-based authentication and supports multiple administration workflows including first-run setup, ongoing configuration management, and operational monitoring.
## Architecture Components
### 1. Configuration System
- **File-based configuration**: Signed Nostr events stored as JSON files following XDG Base Directory specification
- **Database configuration**: Key-value pairs stored in SQLite for runtime configuration
- **Interactive setup**: Command-line wizard for initial server configuration
- **Manual setup**: Scripts for generating signed configuration events
### 2. Authentication & Authorization
- **Nostr-based auth**: All admin operations require valid Nostr event signatures
- **Admin pubkey verification**: Only configured admin public keys can perform admin operations
- **Event validation**: Full cryptographic verification of Nostr events including structure, signature, and expiration
- **Method-specific authorization**: Different event types for different operations (upload, admin, delete, etc.)
### 3. API System
- **RESTful endpoints**: `/api/*` routes for programmatic administration
- **Command-line testing**: Complete test suite using `nak` and `curl`
- **JSON responses**: Structured data for all admin operations
- **CORS support**: Cross-origin requests for web admin interface
### 4. Web Interface (Future)
- **Single-page application**: Self-contained HTML file with inline CSS/JS
- **Real-time monitoring**: Statistics and system health dashboards
- **Configuration management**: GUI for server settings
- **File management**: Browse and manage uploaded blobs
## Configuration System Architecture
### File-based Configuration (Priority 1)
**Location**: Follows XDG Base Directory Specification
- `$XDG_CONFIG_HOME/ginxsom/ginxsom_config_event.json`
- Falls back to `$HOME/.config/ginxsom/ginxsom_config_event.json`
**Format**: Signed Nostr event containing server configuration
```json
{
"kind": 33333,
"created_at": 1704067200,
"tags": [
["server_privkey", "server_private_key_hex"],
["cdn_origin", "https://cdn.example.com"],
["max_file_size", "104857600"],
["nip94_enabled", "true"]
],
"content": "Ginxsom server configuration",
"pubkey": "admin_public_key_hex",
"id": "event_id_hash",
"sig": "event_signature"
}
```
**Loading Process**:
1. Check for file-based config at XDG location
2. Validate Nostr event structure and signature
3. Extract configuration from event tags
4. Apply settings to server (database storage)
5. Fall back to database-only config if file missing/invalid
### Database Configuration (Priority 2)
**Table**: `server_config`
```sql
CREATE TABLE server_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
**Key Configuration Items**:
- `admin_pubkey`: Authorized admin public key
- `admin_enabled`: Enable/disable admin interface
- `cdn_origin`: Base URL for blob access
- `max_file_size`: Maximum upload size in bytes
- `nip94_enabled`: Enable NIP-94 metadata emission
- `auth_rules_enabled`: Enable authentication rules system
### Setup Workflows
#### Interactive Setup (Command Line)
```bash
# First-run detection
if [[ ! -f "$XDG_CONFIG_HOME/ginxsom/ginxsom_config_event.json" ]]; then
echo "=== Ginxsom First-Time Setup Required ==="
echo "1. Run interactive setup wizard"
echo "2. Exit and create config manually"
read -p "Choice (1/2): " choice
if [[ "$choice" == "1" ]]; then
./scripts/setup.sh
else
echo "Manual setup: Run ./scripts/generate_config.sh"
exit 1
fi
fi
```
#### Manual Setup (Script-based)
```bash
# Generate configuration event
./scripts/generate_config.sh --admin-key <admin_pubkey> \
--server-key <server_privkey> \
--cdn-origin "https://cdn.example.com" \
--output "$XDG_CONFIG_HOME/ginxsom/ginxsom_config_event.json"
```
### C Implementation Functions
#### Configuration Loading
```c
// Get XDG-compliant config file path
int get_config_file_path(char* path, size_t path_size);
// Load and validate config event from file
int load_server_config(const char* config_path);
// Extract config from validated event and apply to server
int apply_config_from_event(cJSON* event);
// Interactive setup runner for first-run
int run_interactive_setup(const char* config_path);
```
#### Security Features
- Server private key stored only in memory (never in database)
- Config file must be signed Nostr event
- Full cryptographic validation of config events
- Admin pubkey verification for all operations
## Admin API Specification
### Authentication Model
All admin API endpoints (except `/api/health`) require Nostr authentication:
**Authorization Header Format**:
```
Authorization: Nostr <base64-encoded-event>
```
**Required Event Structure**:
```json
{
"kind": 24242,
"created_at": 1704067200,
"tags": [
["t", "GET"],
["expiration", "1704070800"]
],
"content": "admin_request",
"pubkey": "admin_public_key",
"id": "event_id",
"sig": "event_signature"
}
```
### API Endpoints
#### GET /api/health
**Purpose**: System health check (no authentication required)
**Response**:
```json
{
"status": "success",
"data": {
"database": "connected",
"blob_directory": "accessible",
"server_time": 1704067200,
"uptime": 3600,
"disk_usage": {
"total_bytes": 1073741824,
"used_bytes": 536870912,
"available_bytes": 536870912,
"usage_percent": 50.0
}
}
}
```
#### GET /api/stats
**Purpose**: Server statistics and metrics
**Authentication**: Required (admin pubkey)
**Response**:
```json
{
"status": "success",
"data": {
"total_files": 1234,
"total_bytes": 104857600,
"total_size_mb": 100.0,
"unique_uploaders": 56,
"first_upload": 1693929600,
"last_upload": 1704067200,
"avg_file_size": 85049,
"file_types": {
"image/png": 45,
"image/jpeg": 32,
"application/pdf": 12,
"other": 8
}
}
}
```
#### GET /api/config
**Purpose**: Retrieve current server configuration
**Authentication**: Required (admin pubkey)
**Response**:
```json
{
"status": "success",
"data": {
"cdn_origin": "http://localhost:9001",
"max_file_size": "104857600",
"nip94_enabled": "true",
"auth_rules_enabled": "false",
"auth_cache_ttl": "300"
}
}
```
#### PUT /api/config
**Purpose**: Update server configuration
**Authentication**: Required (admin pubkey)
**Request Body**:
```json
{
"max_file_size": "209715200",
"nip94_enabled": "true",
"cdn_origin": "https://cdn.example.com"
}
```
**Response**:
```json
{
"status": "success",
"message": "Configuration updated successfully",
"updated_keys": ["max_file_size", "cdn_origin"]
}
```
#### GET /api/files
**Purpose**: List recent files with pagination
**Authentication**: Required (admin pubkey)
**Parameters**:
- `limit` (default: 50): Number of files to return
- `offset` (default: 0): Pagination offset
**Response**:
```json
{
"status": "success",
"data": {
"files": [
{
"sha256": "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553",
"size": 184292,
"type": "application/pdf",
"uploaded_at": 1725105921,
"uploader_pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"filename": "document.pdf",
"url": "http://localhost:9001/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf"
}
],
"total": 1234,
"limit": 50,
"offset": 0
}
}
```
## Implementation Status
### ✅ Completed Components
1. **Database-based configuration loading** - Implemented in main.c
2. **Admin API authentication system** - Implemented in admin_api.c
3. **Nostr event validation** - Full cryptographic verification
4. **Admin pubkey verification** - Database-backed authorization
5. **Basic API endpoints** - Health, stats, config, files
### ✅ Recently Completed Components
1. **File-based configuration system** - Fully implemented in main.c with XDG compliance
2. **Interactive setup wizard** - Complete shell script with guided setup process (`scripts/setup.sh`)
3. **Manual config generation** - Full-featured command-line config generator (`scripts/generate_config.sh`)
4. **Testing infrastructure** - Comprehensive admin API test suite (`scripts/test_admin.sh`)
5. **Documentation system** - Complete setup and usage documentation (`scripts/README.md`)
### 📋 Planned Components
1. **Web admin interface** - Single-page HTML application
2. **Enhanced monitoring** - Real-time statistics dashboard
3. **Bulk operations** - Multi-file management APIs
4. **Configuration validation** - Advanced config checking
5. **Audit logging** - Admin action tracking
## Setup Instructions
### 1. Enable Admin Interface
```bash
# Configure admin pubkey and enable interface
sqlite3 db/ginxsom.db << EOF
INSERT OR REPLACE INTO server_config (key, value, description) VALUES
('admin_pubkey', 'your_admin_public_key_here', 'Authorized admin public key'),
('admin_enabled', 'true', 'Enable admin interface');
EOF
```
### 2. Test API Access
```bash
# Generate admin authentication event
ADMIN_PRIVKEY="your_admin_private_key"
EVENT=$(nak event -k 24242 -c "admin_request" \
--tag t="GET" \
--tag expiration="$(date -d '+1 hour' +%s)" \
--sec "$ADMIN_PRIVKEY")
# Test admin API
AUTH_HEADER="Nostr $(echo "$EVENT" | base64 -w 0)"
curl -H "Authorization: $AUTH_HEADER" http://localhost:9001/api/stats
```
### 3. Configure File-based Setup (Future)
```bash
# Create XDG config directory
mkdir -p "$XDG_CONFIG_HOME/ginxsom"
# Generate signed config event
./scripts/generate_config.sh \
--admin-key "your_admin_pubkey" \
--server-key "generated_server_privkey" \
--output "$XDG_CONFIG_HOME/ginxsom/ginxsom_config_event.json"
```
## Security Considerations
### Authentication Security
- **Event expiration**: All admin events must include expiration timestamps
- **Signature validation**: Full secp256k1 cryptographic verification
- **Replay protection**: Event IDs tracked to prevent reuse
- **Admin key rotation**: Support for updating admin pubkeys
### Configuration Security
- **File permissions**: Config files should be readable only by server user
- **Private key handling**: Server private keys never stored in database
- **Config validation**: All configuration changes validated before application
- **Backup verification**: Config events cryptographically verifiable
### Operational Security
- **Access logging**: All admin operations logged with timestamps
- **Rate limiting**: API endpoints protected against abuse
- **Input validation**: All user input sanitized and validated
- **Database security**: Prepared statements prevent SQL injection
## Future Enhancements
### 1. Web Admin Interface
- Self-contained HTML file with inline CSS/JavaScript
- Real-time monitoring dashboards
- Visual configuration management
- File upload/management interface
### 2. Advanced Monitoring
- Performance metrics collection
- Alert system for critical events
- Historical data trending
- Resource usage tracking
### 3. Multi-admin Support
- Multiple authorized admin pubkeys
- Role-based permissions (read-only vs full admin)
- Admin action audit trails
- Delegation capabilities
### 4. Integration Features
- Nostr relay integration for admin events
- Webhook notifications for admin actions
- External authentication providers
- API key management for programmatic access
This specification represents the current understanding and planned development of the Ginxsom admin system, focusing on security, usability, and maintainability.

View File

@@ -60,7 +60,7 @@ show_usage() {
echo " - Git add, commit, push, and create Gitea release"
echo ""
echo "Requirements for Release Mode:"
echo " - ARM64 cross-compiler: sudo apt install gcc-aarch64-linux-gnu"
echo " - For ARM64 builds: make install-arm64-deps (optional - will build x86_64 only if missing)"
echo " - Gitea token in ~/.gitea_token for release uploads"
}
@@ -148,16 +148,6 @@ compile_project() {
fi
}
# Check for ARM64 cross-compiler
check_cross_compiler() {
if ! command -v aarch64-linux-gnu-gcc > /dev/null 2>&1; then
print_error "ARM64/AArch64 cross-compiler not found!"
print_error "Install with: sudo apt install gcc-aarch64-linux-gnu"
return 1
fi
return 0
}
# Function to build release binaries
build_release_binaries() {
print_status "Building release binaries..."
@@ -165,9 +155,9 @@ build_release_binaries() {
# Build x86_64 version
print_status "Building x86_64 version..."
make clean > /dev/null 2>&1
if make CC=gcc > /dev/null 2>&1; then
if [[ -f "src/main" ]]; then
cp src/main c-relay-x86_64
if make x86 > /dev/null 2>&1; then
if [[ -f "build/c_relay_x86" ]]; then
cp build/c_relay_x86 c-relay-x86_64
print_success "x86_64 binary created: c-relay-x86_64"
else
print_error "x86_64 binary not found after compilation"
@@ -178,25 +168,19 @@ build_release_binaries() {
exit 1
fi
# Check for ARM64 cross-compiler
if check_cross_compiler; then
# Build ARM64 version
print_status "Building ARM64 version..."
make clean > /dev/null 2>&1
if make CC=aarch64-linux-gnu-gcc > /dev/null 2>&1; then
if [[ -f "src/main" ]]; then
cp src/main c-relay-arm64
print_success "ARM64 binary created: c-relay-arm64"
else
print_error "ARM64 binary not found after compilation"
exit 1
fi
# Try to build ARM64 version
print_status "Attempting ARM64 build..."
make clean > /dev/null 2>&1
if make arm64 > /dev/null 2>&1; then
if [[ -f "build/c_relay_arm64" ]]; then
cp build/c_relay_arm64 c-relay-arm64
print_success "ARM64 binary created: c-relay-arm64"
else
print_error "ARM64 build failed"
exit 1
print_warning "ARM64 binary not found after compilation"
fi
else
print_warning "ARM64 cross-compiler not available, skipping ARM64 build"
print_warning "ARM64 build failed - ARM64 cross-compilation not properly set up"
print_status "Only x86_64 binary will be included in release"
fi
# Restore normal build
@@ -264,7 +248,7 @@ create_gitea_release() {
fi
local token=$(cat "$HOME/.gitea_token" | tr -d '\n\r')
local api_url="https://git.laantungir.net/api/v1/repos/teknari/c-relay"
local api_url="https://git.laantungir.net/api/v1/repos/laantungir/c-relay"
# Create release
print_status "Creating release $NEW_VERSION..."
@@ -275,13 +259,24 @@ create_gitea_release() {
if echo "$response" | grep -q '"id"'; then
print_success "Created release $NEW_VERSION"
# Upload binaries
upload_release_binaries "$api_url" "$token"
elif echo "$response" | grep -q "already exists"; then
print_warning "Release $NEW_VERSION already exists"
upload_release_binaries "$api_url" "$token"
else
print_warning "Release may already exist or creation failed"
print_status "Attempting to upload to existing release..."
upload_release_binaries "$api_url" "$token"
print_error "Failed to create release $NEW_VERSION"
print_error "Response: $response"
# Try to check if the release exists anyway
print_status "Checking if release exists..."
local check_response=$(curl -s -H "Authorization: token $token" "$api_url/releases/tags/$NEW_VERSION")
if echo "$check_response" | grep -q '"id"'; then
print_warning "Release exists but creation response was unexpected"
upload_release_binaries "$api_url" "$token"
else
print_error "Release does not exist and creation failed"
return 1
fi
fi
}
@@ -290,16 +285,23 @@ upload_release_binaries() {
local api_url="$1"
local token="$2"
# Get release ID
local release_id=$(curl -s -H "Authorization: token $token" \
"$api_url/releases/tags/$NEW_VERSION" | \
grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2)
# Get release ID with more robust parsing
print_status "Getting release ID for $NEW_VERSION..."
local response=$(curl -s -H "Authorization: token $token" "$api_url/releases/tags/$NEW_VERSION")
local release_id=$(echo "$response" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2)
if [[ -z "$release_id" ]]; then
print_error "Could not get release ID for $NEW_VERSION"
print_error "API Response: $response"
# Try to list all releases to debug
print_status "Available releases:"
curl -s -H "Authorization: token $token" "$api_url/releases" | grep -o '"tag_name":"[^"]*"' | head -5
return 1
fi
print_success "Found release ID: $release_id"
# Upload x86_64 binary
if [[ -f "c-relay-x86_64" ]]; then
print_status "Uploading x86_64 binary..."

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -15,9 +15,22 @@ if [ $? -ne 0 ]; then
exit 1
fi
# Check if relay binary exists after build
if [ ! -f "./src/main" ]; then
echo "ERROR: Relay binary not found after build. Build may have failed."
# Check if relay binary exists after build - detect architecture
ARCH=$(uname -m)
case "$ARCH" in
x86_64)
BINARY_PATH="./build/c_relay_x86"
;;
aarch64|arm64)
BINARY_PATH="./build/c_relay_arm64"
;;
*)
BINARY_PATH="./build/c_relay_$ARCH"
;;
esac
if [ ! -f "$BINARY_PATH" ]; then
echo "ERROR: Relay binary not found at $BINARY_PATH after build. Build may have failed."
exit 1
fi
@@ -25,7 +38,7 @@ echo "Build successful. Proceeding with relay restart..."
# Kill existing relay if running
echo "Stopping any existing relay servers..."
pkill -f "./src/main" 2>/dev/null
pkill -f "c_relay_" 2>/dev/null
sleep 2 # Give time for shutdown
# Check if port is still bound
@@ -35,7 +48,7 @@ if lsof -i :8888 >/dev/null 2>&1; then
fi
# Get any remaining processes
REMAINING_PIDS=$(pgrep -f "./src/main" || echo "")
REMAINING_PIDS=$(pgrep -f "c_relay_" || echo "")
if [ -n "$REMAINING_PIDS" ]; then
echo "Force killing remaining processes: $REMAINING_PIDS"
kill -9 $REMAINING_PIDS 2>/dev/null
@@ -55,10 +68,10 @@ fi
# Start relay in background with output redirection
echo "Starting relay server..."
echo "Debug: Current processes: $(ps aux | grep './src/main' | grep -v grep || echo 'None')"
echo "Debug: Current processes: $(ps aux | grep 'c_relay_' | grep -v grep || echo 'None')"
# Start relay in background and capture its PID
./src/main > relay.log 2>&1 &
$BINARY_PATH > relay.log 2>&1 &
RELAY_PID=$!
echo "Started with PID: $RELAY_PID"
@@ -78,9 +91,10 @@ if ps -p "$RELAY_PID" >/dev/null 2>&1; then
echo $RELAY_PID > relay.pid
echo "=== Relay server running in background ==="
echo "To kill relay: pkill -f './src/main'"
echo "To check status: ps aux | grep src/main"
echo "To kill relay: pkill -f 'c_relay_'"
echo "To check status: ps aux | grep c_relay_"
echo "To view logs: tail -f relay.log"
echo "Binary: $BINARY_PATH"
echo "Ready for Nostr client connections!"
else
echo "ERROR: Relay failed to start"

126
relay.log
View File

@@ -1,18 +1,35 @@
=== C Nostr Relay Server ===
[SUCCESS] Database connection established
[SUCCESS] Relay information initialized with default values
[INFO] Initializing NIP-13 Proof of Work configuration
[INFO] PoW Configuration: enabled=true, min_difficulty=0, validation_flags=0x1, mode=full
[INFO] Initializing NIP-40 Expiration Timestamp configuration
[INFO] Expiration Configuration: enabled=true, strict_mode=true, filter_responses=true, grace_period=300 seconds
[INFO] Starting relay server...
[INFO] Starting libwebsockets-based Nostr relay server...
[SUCCESS] WebSocket relay started on ws://127.0.0.1:8888
[INFO] HTTP request received
[INFO] Handling NIP-11 relay information request
[SUCCESS] NIP-11 relay information served successfully
[INFO] HTTP request received
[INFO] Handling NIP-11 relay information request
[SUCCESS] NIP-11 relay information served successfully
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Handling EVENT message with full NIP-01 validation
[SUCCESS] Event stored in database
[SUCCESS] Event validated and stored successfully
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Handling EVENT message with full NIP-01 validation
[SUCCESS] Event stored in database
[SUCCESS] Event validated and stored successfully
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Handling EVENT message with full NIP-01 validation
[WARNING] Event rejected: expired timestamp
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
@@ -34,98 +51,33 @@
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[WARNING] Subscription 'exists_1757082297' not found for removal
[INFO] Closed subscription: exists_1757082297
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[WARNING] Subscription 'exists_1757082298' not found for removal
[INFO] Closed subscription: exists_1757082298
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Handling EVENT message with full NIP-01 validation
[INFO] Event not found for deletion: [INFO] ...
[INFO] Event not found for deletion: [INFO] ...
[SUCCESS] Event stored in database
[INFO] Deletion request processed: 0 events deleted
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[WARNING] Subscription 'exists_1757082301' not found for removal
[INFO] Closed subscription: exists_1757082301
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[WARNING] Subscription 'exists_1757082301' not found for removal
[INFO] Closed subscription: exists_1757082301
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[WARNING] Subscription 'exists_1757082301' not found for removal
[INFO] Closed subscription: exists_1757082301
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Handling EVENT message with full NIP-01 validation
[SUCCESS] Event stored in database
[INFO] Deletion request processed: 0 events deleted
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[WARNING] Subscription 'exists_1757082305' not found for removal
[INFO] Closed subscription: exists_1757082305
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Handling EVENT message with full NIP-01 validation
[INFO] Event not found for deletion: ✗ Cou...
[SUCCESS] Event stored in database
[INFO] Deletion request processed: 0 events deleted
[WARNING] Event rejected: expired timestamp
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Handling REQ message for persistent subscription
[INFO] Added subscription 'exists_1757082309' (total: 1)
[INFO] Executing SQL: SELECT id, pubkey, created_at, kind, content, sig, tags FROM events WHERE 1=1 ORDER BY created_at DESC LIMIT 500
[INFO] Query returned 25 rows
[INFO] Total events sent: 25
[INFO] Added subscription 'filter_test' (total: 1)
[INFO] Executing SQL: SELECT id, pubkey, created_at, kind, content, sig, tags FROM events WHERE 1=1 AND kind IN (1) ORDER BY created_at DESC LIMIT 10
[INFO] Query returned 10 rows
[INFO] Total events sent: 10
[INFO] Received WebSocket message
[INFO] Removed subscription 'exists_1757082309' (total: 0)
[INFO] Closed subscription: exists_1757082309
[INFO] Removed subscription 'filter_test' (total: 0)
[INFO] Closed subscription: filter_test
[INFO] WebSocket connection closed
[WARNING] Subscription 'z[<5B><>.Y' not found for removal
[WARNING] Subscription '<EFBFBD><EFBFBD><15>a' not found for removal
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Handling EVENT message with full NIP-01 validation
[SUCCESS] Event stored in database
[SUCCESS] Event validated and stored successfully
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Handling REQ message for persistent subscription
[INFO] Added subscription 'kind5_1757082309' (total: 1)
[INFO] Executing SQL: SELECT id, pubkey, created_at, kind, content, sig, tags FROM events WHERE 1=1 AND kind IN (5) ORDER BY created_at DESC LIMIT 500
[INFO] Query returned 3 rows
[INFO] Total events sent: 3
[INFO] Received WebSocket message
[INFO] Removed subscription 'kind5_1757082309' (total: 0)
[INFO] Closed subscription: kind5_1757082309
[INFO] Handling EVENT message with full NIP-01 validation
[SUCCESS] Event stored in database
[SUCCESS] Event validated and stored successfully
[INFO] WebSocket connection closed
[WARNING] Subscription '<27>f<EFBFBD><66>.Y' not found for removal
[INFO] HTTP request received
[INFO] Handling NIP-11 relay information request
[SUCCESS] NIP-11 relay information served successfully

View File

@@ -1 +1 @@
682319
743964

BIN
src/main

Binary file not shown.

View File

@@ -14,6 +14,7 @@
// Include nostr_core_lib for Nostr functionality
#include "../nostr_core_lib/cjson/cJSON.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
#include "../nostr_core_lib/nostr_core/nip013.h" // NIP-13: Proof of Work
// Server Configuration
#define DEFAULT_PORT 8888
@@ -28,6 +29,13 @@
#define SUBSCRIPTION_ID_MAX_LENGTH 64
#define CLIENT_IP_MAX_LENGTH 64
// NIP-11 relay information configuration
#define RELAY_NAME_MAX_LENGTH 128
#define RELAY_DESCRIPTION_MAX_LENGTH 1024
#define RELAY_URL_MAX_LENGTH 256
#define RELAY_CONTACT_MAX_LENGTH 128
#define RELAY_PUBKEY_MAX_LENGTH 65 // 64 hex chars + null terminator
// Color constants for logging
#define RED "\033[31m"
#define GREEN "\033[32m"
@@ -41,6 +49,72 @@ static sqlite3* g_db = NULL;
static int g_server_running = 1;
static struct lws_context *ws_context = NULL;
// NIP-11 relay information structure
struct relay_info {
char name[RELAY_NAME_MAX_LENGTH];
char description[RELAY_DESCRIPTION_MAX_LENGTH];
char banner[RELAY_URL_MAX_LENGTH];
char icon[RELAY_URL_MAX_LENGTH];
char pubkey[RELAY_PUBKEY_MAX_LENGTH];
char contact[RELAY_CONTACT_MAX_LENGTH];
char software[RELAY_URL_MAX_LENGTH];
char version[64];
char privacy_policy[RELAY_URL_MAX_LENGTH];
char terms_of_service[RELAY_URL_MAX_LENGTH];
cJSON* supported_nips; // Array of supported NIP numbers
cJSON* limitation; // Server limitations object
cJSON* retention; // Event retention policies array
cJSON* relay_countries; // Array of country codes
cJSON* language_tags; // Array of language tags
cJSON* tags; // Array of content tags
char posting_policy[RELAY_URL_MAX_LENGTH];
cJSON* fees; // Payment fee structure
char payments_url[RELAY_URL_MAX_LENGTH];
};
// Global relay information instance
static struct relay_info g_relay_info = {0};
// NIP-13 PoW configuration structure
struct pow_config {
int enabled; // 0 = disabled, 1 = enabled
int min_pow_difficulty; // Minimum required difficulty (0 = no requirement)
int validation_flags; // Bitflags for validation options
int require_nonce_tag; // 1 = require nonce tag presence
int reject_lower_targets; // 1 = reject if committed < actual difficulty
int strict_format; // 1 = enforce strict nonce tag format
int anti_spam_mode; // 1 = full anti-spam validation
};
// Global PoW configuration instance
static struct pow_config g_pow_config = {
.enabled = 1, // Enable PoW validation by default
.min_pow_difficulty = 0, // No minimum difficulty by default
.validation_flags = NOSTR_POW_VALIDATE_BASIC,
.require_nonce_tag = 0, // Don't require nonce tags by default
.reject_lower_targets = 0, // Allow lower committed targets by default
.strict_format = 0, // Relaxed format validation by default
.anti_spam_mode = 0 // Basic validation by default
};
// NIP-40 Expiration configuration structure
struct expiration_config {
int enabled; // 0 = disabled, 1 = enabled
int strict_mode; // 1 = reject expired events on submission
int filter_responses; // 1 = filter expired events from responses
int delete_expired; // 1 = delete expired events from DB (future feature)
long grace_period; // Grace period in seconds for clock skew
};
// Global expiration configuration instance
static struct expiration_config g_expiration_config = {
.enabled = 1, // Enable expiration handling by default
.strict_mode = 1, // Reject expired events on submission by default
.filter_responses = 1, // Filter expired events from responses by default
.delete_expired = 0, // Don't delete by default (keep for audit)
.grace_period = 300 // 5 minutes grace period for clock skew
};
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
@@ -151,6 +225,22 @@ int mark_event_as_deleted(const char* event_id, const char* deletion_event_id, c
// Forward declaration for database functions
int store_event(cJSON* event);
// Forward declarations for NIP-11 relay information handling
void init_relay_info();
void cleanup_relay_info();
cJSON* generate_relay_info_json();
int handle_nip11_http_request(struct lws* wsi, const char* accept_header);
// Forward declarations for NIP-13 PoW validation
void init_pow_config();
int validate_event_pow(cJSON* event, char* error_message, size_t error_size);
// Forward declarations for NIP-40 expiration handling
void init_expiration_config();
long extract_expiration_timestamp(cJSON* tags);
int is_event_expired(cJSON* event, time_t current_time);
int validate_event_expiration(cJSON* event, char* error_message, size_t error_size);
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
@@ -585,6 +675,19 @@ int broadcast_event_to_subscriptions(cJSON* event) {
return 0;
}
// Check if event is expired and should not be broadcast (NIP-40)
if (g_expiration_config.enabled && g_expiration_config.filter_responses) {
time_t current_time = time(NULL);
if (is_event_expired(event, current_time)) {
char debug_msg[256];
cJSON* event_id_obj = cJSON_GetObjectItem(event, "id");
const char* event_id = event_id_obj ? cJSON_GetStringValue(event_id_obj) : "unknown";
snprintf(debug_msg, sizeof(debug_msg), "Skipping broadcast of expired event: %.16s", event_id);
log_info(debug_msg);
return 0; // Don't broadcast expired events
}
}
int broadcasts = 0;
pthread_mutex_lock(&g_subscription_manager.subscriptions_lock);
@@ -922,8 +1025,11 @@ int handle_deletion_request(cJSON* event, char* error_message, size_t error_size
}
const char* requester_pubkey = cJSON_GetStringValue(pubkey_obj);
// Extract deletion event ID and reason (for potential logging)
const char* deletion_event_id = cJSON_GetStringValue(event_id_obj);
const char* reason = content_obj ? cJSON_GetStringValue(content_obj) : "";
(void)deletion_event_id; // Mark as intentionally unused for now
(void)reason; // Mark as intentionally unused for now
long deletion_timestamp = (long)cJSON_GetNumberValue(created_at_obj);
if (!cJSON_IsArray(tags_obj)) {
@@ -1011,7 +1117,7 @@ int handle_deletion_request(cJSON* event, char* error_message, size_t error_size
snprintf(debug_msg, sizeof(debug_msg), "Deletion request processed: %d events deleted", deleted_count);
log_info(debug_msg);
snprintf(error_message, error_size, ""); // Success
error_message[0] = '\0'; // Success - empty error message
return 0;
}
@@ -1176,6 +1282,658 @@ int mark_event_as_deleted(const char* event_id, const char* deletion_event_id, c
return 0;
}
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
// NIP-11 RELAY INFORMATION DOCUMENT
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
// Initialize relay information with default values
void init_relay_info() {
// Set default relay information
strncpy(g_relay_info.name, "C Nostr Relay", sizeof(g_relay_info.name) - 1);
strncpy(g_relay_info.description, "A high-performance Nostr relay implemented in C with SQLite storage", sizeof(g_relay_info.description) - 1);
strncpy(g_relay_info.software, "https://github.com/teknari/c-relay", sizeof(g_relay_info.software) - 1);
strncpy(g_relay_info.version, "0.1.0", sizeof(g_relay_info.version) - 1);
// Initialize supported NIPs array
g_relay_info.supported_nips = cJSON_CreateArray();
if (g_relay_info.supported_nips) {
cJSON_AddItemToArray(g_relay_info.supported_nips, cJSON_CreateNumber(1)); // NIP-01: Basic protocol
cJSON_AddItemToArray(g_relay_info.supported_nips, cJSON_CreateNumber(9)); // NIP-09: Event deletion
cJSON_AddItemToArray(g_relay_info.supported_nips, cJSON_CreateNumber(11)); // NIP-11: Relay information
cJSON_AddItemToArray(g_relay_info.supported_nips, cJSON_CreateNumber(13)); // NIP-13: Proof of Work
cJSON_AddItemToArray(g_relay_info.supported_nips, cJSON_CreateNumber(15)); // NIP-15: EOSE
cJSON_AddItemToArray(g_relay_info.supported_nips, cJSON_CreateNumber(20)); // NIP-20: Command results
cJSON_AddItemToArray(g_relay_info.supported_nips, cJSON_CreateNumber(40)); // NIP-40: Expiration Timestamp
}
// Initialize server limitations
g_relay_info.limitation = cJSON_CreateObject();
if (g_relay_info.limitation) {
cJSON_AddNumberToObject(g_relay_info.limitation, "max_message_length", 16384);
cJSON_AddNumberToObject(g_relay_info.limitation, "max_subscriptions", MAX_SUBSCRIPTIONS_PER_CLIENT);
cJSON_AddNumberToObject(g_relay_info.limitation, "max_limit", 5000);
cJSON_AddNumberToObject(g_relay_info.limitation, "max_subid_length", SUBSCRIPTION_ID_MAX_LENGTH);
cJSON_AddNumberToObject(g_relay_info.limitation, "max_event_tags", 100);
cJSON_AddNumberToObject(g_relay_info.limitation, "max_content_length", 8196);
cJSON_AddNumberToObject(g_relay_info.limitation, "min_pow_difficulty", g_pow_config.min_pow_difficulty);
cJSON_AddBoolToObject(g_relay_info.limitation, "auth_required", cJSON_False);
cJSON_AddBoolToObject(g_relay_info.limitation, "payment_required", cJSON_False);
cJSON_AddBoolToObject(g_relay_info.limitation, "restricted_writes", cJSON_False);
cJSON_AddNumberToObject(g_relay_info.limitation, "created_at_lower_limit", 0);
cJSON_AddNumberToObject(g_relay_info.limitation, "created_at_upper_limit", 2147483647);
cJSON_AddNumberToObject(g_relay_info.limitation, "default_limit", 500);
}
// Initialize empty retention policies (can be configured later)
g_relay_info.retention = cJSON_CreateArray();
// Initialize language tags - set to global for now
g_relay_info.language_tags = cJSON_CreateArray();
if (g_relay_info.language_tags) {
cJSON_AddItemToArray(g_relay_info.language_tags, cJSON_CreateString("*"));
}
// Initialize relay countries - set to global for now
g_relay_info.relay_countries = cJSON_CreateArray();
if (g_relay_info.relay_countries) {
cJSON_AddItemToArray(g_relay_info.relay_countries, cJSON_CreateString("*"));
}
// Initialize content tags as empty array
g_relay_info.tags = cJSON_CreateArray();
// Initialize fees as empty object (no payment required by default)
g_relay_info.fees = cJSON_CreateObject();
log_success("Relay information initialized with default values");
}
// Clean up relay information JSON objects
void cleanup_relay_info() {
if (g_relay_info.supported_nips) {
cJSON_Delete(g_relay_info.supported_nips);
g_relay_info.supported_nips = NULL;
}
if (g_relay_info.limitation) {
cJSON_Delete(g_relay_info.limitation);
g_relay_info.limitation = NULL;
}
if (g_relay_info.retention) {
cJSON_Delete(g_relay_info.retention);
g_relay_info.retention = NULL;
}
if (g_relay_info.language_tags) {
cJSON_Delete(g_relay_info.language_tags);
g_relay_info.language_tags = NULL;
}
if (g_relay_info.relay_countries) {
cJSON_Delete(g_relay_info.relay_countries);
g_relay_info.relay_countries = NULL;
}
if (g_relay_info.tags) {
cJSON_Delete(g_relay_info.tags);
g_relay_info.tags = NULL;
}
if (g_relay_info.fees) {
cJSON_Delete(g_relay_info.fees);
g_relay_info.fees = NULL;
}
}
// Generate NIP-11 compliant JSON document
cJSON* generate_relay_info_json() {
cJSON* info = cJSON_CreateObject();
if (!info) {
log_error("Failed to create relay info JSON object");
return NULL;
}
// Add basic relay information
if (strlen(g_relay_info.name) > 0) {
cJSON_AddStringToObject(info, "name", g_relay_info.name);
}
if (strlen(g_relay_info.description) > 0) {
cJSON_AddStringToObject(info, "description", g_relay_info.description);
}
if (strlen(g_relay_info.banner) > 0) {
cJSON_AddStringToObject(info, "banner", g_relay_info.banner);
}
if (strlen(g_relay_info.icon) > 0) {
cJSON_AddStringToObject(info, "icon", g_relay_info.icon);
}
if (strlen(g_relay_info.pubkey) > 0) {
cJSON_AddStringToObject(info, "pubkey", g_relay_info.pubkey);
}
if (strlen(g_relay_info.contact) > 0) {
cJSON_AddStringToObject(info, "contact", g_relay_info.contact);
}
// Add supported NIPs
if (g_relay_info.supported_nips) {
cJSON_AddItemToObject(info, "supported_nips", cJSON_Duplicate(g_relay_info.supported_nips, 1));
}
// Add software information
if (strlen(g_relay_info.software) > 0) {
cJSON_AddStringToObject(info, "software", g_relay_info.software);
}
if (strlen(g_relay_info.version) > 0) {
cJSON_AddStringToObject(info, "version", g_relay_info.version);
}
// Add policies
if (strlen(g_relay_info.privacy_policy) > 0) {
cJSON_AddStringToObject(info, "privacy_policy", g_relay_info.privacy_policy);
}
if (strlen(g_relay_info.terms_of_service) > 0) {
cJSON_AddStringToObject(info, "terms_of_service", g_relay_info.terms_of_service);
}
if (strlen(g_relay_info.posting_policy) > 0) {
cJSON_AddStringToObject(info, "posting_policy", g_relay_info.posting_policy);
}
// Add server limitations
if (g_relay_info.limitation) {
cJSON_AddItemToObject(info, "limitation", cJSON_Duplicate(g_relay_info.limitation, 1));
}
// Add retention policies if configured
if (g_relay_info.retention && cJSON_GetArraySize(g_relay_info.retention) > 0) {
cJSON_AddItemToObject(info, "retention", cJSON_Duplicate(g_relay_info.retention, 1));
}
// Add geographical and language information
if (g_relay_info.relay_countries) {
cJSON_AddItemToObject(info, "relay_countries", cJSON_Duplicate(g_relay_info.relay_countries, 1));
}
if (g_relay_info.language_tags) {
cJSON_AddItemToObject(info, "language_tags", cJSON_Duplicate(g_relay_info.language_tags, 1));
}
if (g_relay_info.tags && cJSON_GetArraySize(g_relay_info.tags) > 0) {
cJSON_AddItemToObject(info, "tags", cJSON_Duplicate(g_relay_info.tags, 1));
}
// Add payment information if configured
if (strlen(g_relay_info.payments_url) > 0) {
cJSON_AddStringToObject(info, "payments_url", g_relay_info.payments_url);
}
if (g_relay_info.fees && cJSON_GetObjectItem(g_relay_info.fees, "admission")) {
cJSON_AddItemToObject(info, "fees", cJSON_Duplicate(g_relay_info.fees, 1));
}
return info;
}
// Handle NIP-11 HTTP request
int handle_nip11_http_request(struct lws* wsi, const char* accept_header) {
log_info("Handling NIP-11 relay information request");
// Check if client accepts application/nostr+json
int accepts_nostr_json = 0;
if (accept_header) {
if (strstr(accept_header, "application/nostr+json") != NULL) {
accepts_nostr_json = 1;
}
}
if (!accepts_nostr_json) {
log_warning("HTTP request without proper Accept header for NIP-11");
// Return 406 Not Acceptable
unsigned char buf[LWS_PRE + 256];
unsigned char *p = &buf[LWS_PRE];
unsigned char *start = p;
unsigned char *end = &buf[sizeof(buf) - 1];
if (lws_add_http_header_status(wsi, HTTP_STATUS_NOT_ACCEPTABLE, &p, end)) {
return -1;
}
if (lws_add_http_header_by_token(wsi, WSI_TOKEN_HTTP_CONTENT_TYPE, (unsigned char*)"text/plain", 10, &p, end)) {
return -1;
}
if (lws_add_http_header_content_length(wsi, 0, &p, end)) {
return -1;
}
if (lws_finalize_http_header(wsi, &p, end)) {
return -1;
}
lws_write(wsi, start, p - start, LWS_WRITE_HTTP_HEADERS);
return -1; // Close connection
}
// Generate relay information JSON
cJSON* info_json = generate_relay_info_json();
if (!info_json) {
log_error("Failed to generate relay info JSON");
unsigned char buf[LWS_PRE + 256];
unsigned char *p = &buf[LWS_PRE];
unsigned char *start = p;
unsigned char *end = &buf[sizeof(buf) - 1];
if (lws_add_http_header_status(wsi, HTTP_STATUS_INTERNAL_SERVER_ERROR, &p, end)) {
return -1;
}
if (lws_add_http_header_by_token(wsi, WSI_TOKEN_HTTP_CONTENT_TYPE, (unsigned char*)"text/plain", 10, &p, end)) {
return -1;
}
if (lws_add_http_header_content_length(wsi, 0, &p, end)) {
return -1;
}
if (lws_finalize_http_header(wsi, &p, end)) {
return -1;
}
lws_write(wsi, start, p - start, LWS_WRITE_HTTP_HEADERS);
return -1;
}
char* json_string = cJSON_Print(info_json);
cJSON_Delete(info_json);
if (!json_string) {
log_error("Failed to serialize relay info JSON");
unsigned char buf[LWS_PRE + 256];
unsigned char *p = &buf[LWS_PRE];
unsigned char *start = p;
unsigned char *end = &buf[sizeof(buf) - 1];
if (lws_add_http_header_status(wsi, HTTP_STATUS_INTERNAL_SERVER_ERROR, &p, end)) {
return -1;
}
if (lws_add_http_header_by_token(wsi, WSI_TOKEN_HTTP_CONTENT_TYPE, (unsigned char*)"text/plain", 10, &p, end)) {
return -1;
}
if (lws_add_http_header_content_length(wsi, 0, &p, end)) {
return -1;
}
if (lws_finalize_http_header(wsi, &p, end)) {
return -1;
}
lws_write(wsi, start, p - start, LWS_WRITE_HTTP_HEADERS);
return -1;
}
size_t json_len = strlen(json_string);
// Prepare HTTP response with CORS headers
unsigned char buf[LWS_PRE + 1024];
unsigned char *p = &buf[LWS_PRE];
unsigned char *start = p;
unsigned char *end = &buf[sizeof(buf) - 1];
// Add status
if (lws_add_http_header_status(wsi, HTTP_STATUS_OK, &p, end)) {
free(json_string);
return -1;
}
// Add content type
if (lws_add_http_header_by_token(wsi, WSI_TOKEN_HTTP_CONTENT_TYPE,
(unsigned char*)"application/nostr+json", 22, &p, end)) {
free(json_string);
return -1;
}
// Add content length
if (lws_add_http_header_content_length(wsi, json_len, &p, end)) {
free(json_string);
return -1;
}
// Add CORS headers as required by NIP-11
if (lws_add_http_header_by_name(wsi, (unsigned char*)"access-control-allow-origin:",
(unsigned char*)"*", 1, &p, end)) {
free(json_string);
return -1;
}
if (lws_add_http_header_by_name(wsi, (unsigned char*)"access-control-allow-headers:",
(unsigned char*)"content-type, accept", 20, &p, end)) {
free(json_string);
return -1;
}
if (lws_add_http_header_by_name(wsi, (unsigned char*)"access-control-allow-methods:",
(unsigned char*)"GET, OPTIONS", 12, &p, end)) {
free(json_string);
return -1;
}
// Finalize headers
if (lws_finalize_http_header(wsi, &p, end)) {
free(json_string);
return -1;
}
// Write headers
if (lws_write(wsi, start, p - start, LWS_WRITE_HTTP_HEADERS) < 0) {
free(json_string);
return -1;
}
// Write JSON body
unsigned char *json_buf = malloc(LWS_PRE + json_len);
if (!json_buf) {
free(json_string);
return -1;
}
memcpy(json_buf + LWS_PRE, json_string, json_len);
if (lws_write(wsi, json_buf + LWS_PRE, json_len, LWS_WRITE_HTTP) < 0) {
free(json_string);
free(json_buf);
return -1;
}
free(json_buf);
free(json_string);
log_success("NIP-11 relay information served successfully");
return 0;
}
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
// NIP-13 PROOF OF WORK VALIDATION
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
// Initialize PoW configuration with environment variables and defaults
void init_pow_config() {
log_info("Initializing NIP-13 Proof of Work configuration");
// Initialize with defaults (already set in struct initialization)
// Check environment variables for configuration
const char* pow_enabled_env = getenv("RELAY_POW_ENABLED");
if (pow_enabled_env) {
g_pow_config.enabled = (strcmp(pow_enabled_env, "1") == 0 ||
strcmp(pow_enabled_env, "true") == 0 ||
strcmp(pow_enabled_env, "yes") == 0);
}
const char* min_diff_env = getenv("RELAY_MIN_POW_DIFFICULTY");
if (min_diff_env) {
int min_diff = atoi(min_diff_env);
if (min_diff >= 0 && min_diff <= 64) { // Reasonable bounds
g_pow_config.min_pow_difficulty = min_diff;
}
}
const char* pow_mode_env = getenv("RELAY_POW_MODE");
if (pow_mode_env) {
if (strcmp(pow_mode_env, "strict") == 0) {
g_pow_config.validation_flags = NOSTR_POW_VALIDATE_ANTI_SPAM | NOSTR_POW_STRICT_FORMAT;
g_pow_config.require_nonce_tag = 1;
g_pow_config.reject_lower_targets = 1;
g_pow_config.strict_format = 1;
g_pow_config.anti_spam_mode = 1;
log_info("PoW configured in strict anti-spam mode");
} else if (strcmp(pow_mode_env, "full") == 0) {
g_pow_config.validation_flags = NOSTR_POW_VALIDATE_FULL;
g_pow_config.require_nonce_tag = 1;
log_info("PoW configured in full validation mode");
} else if (strcmp(pow_mode_env, "basic") == 0) {
g_pow_config.validation_flags = NOSTR_POW_VALIDATE_BASIC;
log_info("PoW configured in basic validation mode");
} else if (strcmp(pow_mode_env, "disabled") == 0) {
g_pow_config.enabled = 0;
log_info("PoW validation disabled via RELAY_POW_MODE");
}
}
// Log final configuration
char config_msg[512];
snprintf(config_msg, sizeof(config_msg),
"PoW Configuration: enabled=%s, min_difficulty=%d, validation_flags=0x%x, mode=%s",
g_pow_config.enabled ? "true" : "false",
g_pow_config.min_pow_difficulty,
g_pow_config.validation_flags,
g_pow_config.anti_spam_mode ? "anti-spam" :
(g_pow_config.validation_flags & NOSTR_POW_VALIDATE_FULL) ? "full" : "basic");
log_info(config_msg);
}
// Validate event Proof of Work according to NIP-13
int validate_event_pow(cJSON* event, char* error_message, size_t error_size) {
if (!g_pow_config.enabled) {
return 0; // PoW validation disabled
}
if (!event) {
snprintf(error_message, error_size, "pow: null event");
return NOSTR_ERROR_INVALID_INPUT;
}
// If min_pow_difficulty is 0, only validate events that have nonce tags
// This allows events without PoW when difficulty requirement is 0
if (g_pow_config.min_pow_difficulty == 0) {
cJSON* tags = cJSON_GetObjectItem(event, "tags");
int has_nonce_tag = 0;
if (tags && cJSON_IsArray(tags)) {
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, tags) {
if (cJSON_IsArray(tag) && cJSON_GetArraySize(tag) >= 2) {
cJSON* tag_name = cJSON_GetArrayItem(tag, 0);
if (cJSON_IsString(tag_name)) {
const char* name = cJSON_GetStringValue(tag_name);
if (name && strcmp(name, "nonce") == 0) {
has_nonce_tag = 1;
break;
}
}
}
}
}
// If no minimum difficulty required and no nonce tag, skip PoW validation
if (!has_nonce_tag) {
return 0; // Accept event without PoW when min_difficulty=0
}
}
// Perform PoW validation using nostr_core_lib
nostr_pow_result_t pow_result;
int validation_result = nostr_validate_pow(event, g_pow_config.min_pow_difficulty,
g_pow_config.validation_flags, &pow_result);
if (validation_result != NOSTR_SUCCESS) {
// Handle specific error cases with appropriate messages
switch (validation_result) {
case NOSTR_ERROR_NIP13_INSUFFICIENT:
snprintf(error_message, error_size,
"pow: insufficient difficulty: %d < %d",
pow_result.actual_difficulty, g_pow_config.min_pow_difficulty);
log_warning("Event rejected: insufficient PoW difficulty");
break;
case NOSTR_ERROR_NIP13_NO_NONCE_TAG:
// This should not happen with min_difficulty=0 after our check above
if (g_pow_config.min_pow_difficulty > 0) {
snprintf(error_message, error_size, "pow: missing required nonce tag");
log_warning("Event rejected: missing nonce tag");
} else {
return 0; // Allow when min_difficulty=0
}
break;
case NOSTR_ERROR_NIP13_INVALID_NONCE_TAG:
snprintf(error_message, error_size, "pow: invalid nonce tag format");
log_warning("Event rejected: invalid nonce tag format");
break;
case NOSTR_ERROR_NIP13_TARGET_MISMATCH:
snprintf(error_message, error_size,
"pow: committed target (%d) lower than minimum (%d)",
pow_result.committed_target, g_pow_config.min_pow_difficulty);
log_warning("Event rejected: committed target too low (anti-spam protection)");
break;
case NOSTR_ERROR_NIP13_CALCULATION:
snprintf(error_message, error_size, "pow: difficulty calculation failed");
log_error("PoW difficulty calculation error");
break;
case NOSTR_ERROR_EVENT_INVALID_ID:
snprintf(error_message, error_size, "pow: invalid event ID format");
log_warning("Event rejected: invalid event ID for PoW calculation");
break;
default:
snprintf(error_message, error_size, "pow: validation failed - %s",
strlen(pow_result.error_detail) > 0 ? pow_result.error_detail : "unknown error");
log_warning("Event rejected: PoW validation failed");
}
return validation_result;
}
// Log successful PoW validation (only if minimum difficulty is required)
if (g_pow_config.min_pow_difficulty > 0 || pow_result.has_nonce_tag) {
char debug_msg[256];
snprintf(debug_msg, sizeof(debug_msg),
"PoW validated: difficulty=%d, target=%d, nonce=%llu%s",
pow_result.actual_difficulty,
pow_result.committed_target,
(unsigned long long)pow_result.nonce_value,
pow_result.has_nonce_tag ? "" : " (no nonce tag)");
log_info(debug_msg);
}
return 0; // Success
}
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
// NIP-40 EXPIRATION TIMESTAMP HANDLING
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
// Initialize expiration configuration with environment variables and defaults
void init_expiration_config() {
log_info("Initializing NIP-40 Expiration Timestamp configuration");
// Check environment variables for configuration
const char* exp_enabled_env = getenv("RELAY_EXPIRATION_ENABLED");
if (exp_enabled_env) {
g_expiration_config.enabled = (strcmp(exp_enabled_env, "1") == 0 ||
strcmp(exp_enabled_env, "true") == 0 ||
strcmp(exp_enabled_env, "yes") == 0);
}
const char* exp_strict_env = getenv("RELAY_EXPIRATION_STRICT");
if (exp_strict_env) {
g_expiration_config.strict_mode = (strcmp(exp_strict_env, "1") == 0 ||
strcmp(exp_strict_env, "true") == 0 ||
strcmp(exp_strict_env, "yes") == 0);
}
const char* exp_filter_env = getenv("RELAY_EXPIRATION_FILTER");
if (exp_filter_env) {
g_expiration_config.filter_responses = (strcmp(exp_filter_env, "1") == 0 ||
strcmp(exp_filter_env, "true") == 0 ||
strcmp(exp_filter_env, "yes") == 0);
}
const char* exp_delete_env = getenv("RELAY_EXPIRATION_DELETE");
if (exp_delete_env) {
g_expiration_config.delete_expired = (strcmp(exp_delete_env, "1") == 0 ||
strcmp(exp_delete_env, "true") == 0 ||
strcmp(exp_delete_env, "yes") == 0);
}
const char* exp_grace_env = getenv("RELAY_EXPIRATION_GRACE_PERIOD");
if (exp_grace_env) {
long grace_period = atol(exp_grace_env);
if (grace_period >= 0 && grace_period <= 86400) { // Max 24 hours
g_expiration_config.grace_period = grace_period;
}
}
// Log final configuration
char config_msg[512];
snprintf(config_msg, sizeof(config_msg),
"Expiration Configuration: enabled=%s, strict_mode=%s, filter_responses=%s, grace_period=%ld seconds",
g_expiration_config.enabled ? "true" : "false",
g_expiration_config.strict_mode ? "true" : "false",
g_expiration_config.filter_responses ? "true" : "false",
g_expiration_config.grace_period);
log_info(config_msg);
}
// Extract expiration timestamp from event tags
long extract_expiration_timestamp(cJSON* tags) {
if (!tags || !cJSON_IsArray(tags)) {
return 0; // No expiration
}
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, tags) {
if (cJSON_IsArray(tag) && cJSON_GetArraySize(tag) >= 2) {
cJSON* tag_name = cJSON_GetArrayItem(tag, 0);
cJSON* tag_value = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(tag_name) && cJSON_IsString(tag_value)) {
const char* name = cJSON_GetStringValue(tag_name);
const char* value = cJSON_GetStringValue(tag_value);
if (name && value && strcmp(name, "expiration") == 0) {
long expiration_ts = atol(value);
if (expiration_ts > 0) {
return expiration_ts;
}
}
}
}
}
return 0; // No valid expiration tag found
}
// Check if event is currently expired
int is_event_expired(cJSON* event, time_t current_time) {
if (!event) {
return 0; // Invalid event, not expired
}
cJSON* tags = cJSON_GetObjectItem(event, "tags");
long expiration_ts = extract_expiration_timestamp(tags);
if (expiration_ts == 0) {
return 0; // No expiration timestamp, not expired
}
// Check if current time exceeds expiration + grace period
return (current_time > (expiration_ts + g_expiration_config.grace_period));
}
// Validate event expiration according to NIP-40
int validate_event_expiration(cJSON* event, char* error_message, size_t error_size) {
if (!g_expiration_config.enabled) {
return 0; // Expiration validation disabled
}
if (!event) {
snprintf(error_message, error_size, "expiration: null event");
return -1;
}
// Check if event is expired
time_t current_time = time(NULL);
if (is_event_expired(event, current_time)) {
if (g_expiration_config.strict_mode) {
cJSON* tags = cJSON_GetObjectItem(event, "tags");
long expiration_ts = extract_expiration_timestamp(tags);
snprintf(error_message, error_size,
"invalid: event expired (expiration=%ld, current=%ld, grace=%ld)",
expiration_ts, (long)current_time, g_expiration_config.grace_period);
log_warning("Event rejected: expired timestamp");
return -1;
} else {
// In non-strict mode, log but allow expired events
char debug_msg[256];
snprintf(debug_msg, sizeof(debug_msg),
"Accepting expired event (strict_mode disabled)");
log_info(debug_msg);
}
}
return 0; // Success
}
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
// DATABASE FUNCTIONS
@@ -1584,6 +2342,9 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
char* sql_ptr = sql + strlen(sql);
int remaining = sizeof(sql) - strlen(sql);
// Note: Expiration filtering will be done at application level
// after retrieving events to ensure compatibility with all SQLite versions
// Handle kinds filter
cJSON* kinds = cJSON_GetObjectItem(filter, "kinds");
if (kinds && cJSON_IsArray(kinds)) {
@@ -1712,6 +2473,16 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
cJSON_AddItemToObject(event, "tags", tags);
// Check expiration filtering (NIP-40) at application level
if (g_expiration_config.enabled && g_expiration_config.filter_responses) {
time_t current_time = time(NULL);
if (is_event_expired(event, current_time)) {
// Skip this expired event
cJSON_Delete(event);
continue;
}
}
// Send EVENT message
cJSON* event_msg = cJSON_CreateArray();
cJSON_AddItemToArray(event_msg, cJSON_CreateString("EVENT"));
@@ -1801,14 +2572,26 @@ int handle_event_message(cJSON* event, char* error_message, size_t error_size) {
return signature_result;
}
// Step 3: Complete event validation (combines structure + signature + additional checks)
// Step 3: Validate Proof of Work (NIP-13) if enabled
int pow_result = validate_event_pow(event, error_message, error_size);
if (pow_result != 0) {
return pow_result; // PoW validation failed, error message already set
}
// Step 4: Validate expiration timestamp (NIP-40) if enabled
int expiration_result = validate_event_expiration(event, error_message, error_size);
if (expiration_result != 0) {
return expiration_result; // Expiration validation failed, error message already set
}
// Step 5: Complete event validation (combines structure + signature + additional checks)
int validation_result = nostr_validate_event(event);
if (validation_result != NOSTR_SUCCESS) {
snprintf(error_message, error_size, "invalid: complete event validation failed");
return validation_result;
}
// Step 4: Check for special event types and handle accordingly
// Step 6: Check for special event types and handle accordingly
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* created_at_obj = cJSON_GetObjectItem(event, "created_at");
@@ -1843,14 +2626,14 @@ int handle_event_message(cJSON* event, char* error_message, size_t error_size) {
}
} else if (event_type == EVENT_TYPE_EPHEMERAL) {
// Ephemeral events should not be stored
snprintf(error_message, error_size, ""); // Success but no storage
error_message[0] = '\0'; // Success but no storage - empty error message
return 0; // Accept but don't store
}
}
// Step 5: Store event in database
// Step 7: Store event in database
if (store_event(event) == 0) {
snprintf(error_message, error_size, ""); // Success
error_message[0] = '\0'; // Success - empty error message
log_success("Event validated and stored successfully");
return 0;
}
@@ -1873,6 +2656,43 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
struct per_session_data *pss = (struct per_session_data *)user;
switch (reason) {
case LWS_CALLBACK_HTTP:
// Handle NIP-11 relay information requests (HTTP GET to root path)
{
char *requested_uri = (char *)in;
log_info("HTTP request received");
// Check if this is a GET request to the root path
if (strcmp(requested_uri, "/") == 0) {
// Get Accept header
char accept_header[256] = {0};
int header_len = lws_hdr_copy(wsi, accept_header, sizeof(accept_header) - 1, WSI_TOKEN_HTTP_ACCEPT);
if (header_len > 0) {
accept_header[header_len] = '\0';
// Handle NIP-11 request
if (handle_nip11_http_request(wsi, accept_header) == 0) {
return 0; // Successfully handled
}
} else {
log_warning("HTTP request without Accept header");
}
// Return 404 for other requests
lws_return_http_status(wsi, HTTP_STATUS_NOT_FOUND, NULL);
return -1;
}
// Return 404 for non-root paths
lws_return_http_status(wsi, HTTP_STATUS_NOT_FOUND, NULL);
return -1;
}
case LWS_CALLBACK_HTTP_WRITEABLE:
// HTTP response continuation if needed
break;
case LWS_CALLBACK_ESTABLISHED:
log_info("WebSocket connection established");
memset(pss, 0, sizeof(*pss));
@@ -2175,12 +2995,22 @@ int main(int argc, char* argv[]) {
return 1;
}
// Initialize NIP-11 relay information
init_relay_info();
// Initialize NIP-13 PoW configuration
init_pow_config();
// Initialize NIP-40 expiration configuration
init_expiration_config();
log_info("Starting relay server...");
// Start WebSocket Nostr relay server
int result = start_websocket_relay();
// Cleanup
cleanup_relay_info();
nostr_cleanup();
close_database();

432
tests/11_nip_information.sh Executable file
View File

@@ -0,0 +1,432 @@
#!/bin/bash
# NIP-11 Relay Information Document Test
# Tests HTTP endpoint for relay information according to NIP-11 specification
set -e # Exit on any error
# Color constants
RED='\033[31m'
GREEN='\033[32m'
YELLOW='\033[33m'
BLUE='\033[34m'
BOLD='\033[1m'
RESET='\033[0m'
# Test configuration
RELAY_URL="http://127.0.0.1:8888"
RELAY_WS_URL="ws://127.0.0.1:8888"
# Print functions
print_header() {
echo -e "${BLUE}${BOLD}=== $1 ===${RESET}"
}
print_step() {
echo -e "${YELLOW}[STEP]${RESET} $1"
}
print_success() {
echo -e "${GREEN}${RESET} $1"
}
print_error() {
echo -e "${RED}${RESET} $1"
}
print_info() {
echo -e "${BLUE}[INFO]${RESET} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${RESET} $1"
}
# Test functions
test_http_with_correct_header() {
print_step "Testing HTTP request with correct Accept header"
local response=""
local http_code=""
if command -v curl &> /dev/null; then
# Use curl to test with proper Accept header
response=$(curl -s -H "Accept: application/nostr+json" "$RELAY_URL/" 2>/dev/null || echo "")
http_code=$(curl -s -o /dev/null -w "%{http_code}" -H "Accept: application/nostr+json" "$RELAY_URL/" 2>/dev/null || echo "000")
else
print_error "curl command not found - required for NIP-11 testing"
return 1
fi
if [[ "$http_code" == "200" ]]; then
print_success "HTTP 200 OK received with correct Accept header"
# Validate JSON response
if echo "$response" | jq . >/dev/null 2>&1; then
print_success "Response is valid JSON"
return 0
else
print_error "Response is not valid JSON"
return 1
fi
else
print_error "Expected HTTP 200, got HTTP $http_code"
return 1
fi
}
test_http_without_header() {
print_step "Testing HTTP request without Accept header (should return 406)"
local http_code=""
if command -v curl &> /dev/null; then
http_code=$(curl -s -o /dev/null -w "%{http_code}" "$RELAY_URL/" 2>/dev/null || echo "000")
else
print_error "curl command not found - required for NIP-11 testing"
return 1
fi
if [[ "$http_code" == "406" ]]; then
print_success "HTTP 406 Not Acceptable received without proper Accept header"
return 0
else
print_error "Expected HTTP 406, got HTTP $http_code"
return 1
fi
}
test_http_with_wrong_header() {
print_step "Testing HTTP request with wrong Accept header (should return 406)"
local http_code=""
if command -v curl &> /dev/null; then
http_code=$(curl -s -o /dev/null -w "%{http_code}" -H "Accept: application/json" "$RELAY_URL/" 2>/dev/null || echo "000")
else
print_error "curl command not found - required for NIP-11 testing"
return 1
fi
if [[ "$http_code" == "406" ]]; then
print_success "HTTP 406 Not Acceptable received with wrong Accept header"
return 0
else
print_error "Expected HTTP 406, got HTTP $http_code"
return 1
fi
}
test_cors_headers() {
print_step "Testing CORS headers presence"
local headers=""
if command -v curl &> /dev/null; then
headers=$(curl -s -I -H "Accept: application/nostr+json" "$RELAY_URL/" 2>/dev/null || echo "")
else
print_error "curl command not found - required for NIP-11 testing"
return 1
fi
local cors_origin_found=false
local cors_headers_found=false
local cors_methods_found=false
if echo "$headers" | grep -qi "access-control-allow-origin"; then
cors_origin_found=true
print_success "Access-Control-Allow-Origin header found"
fi
if echo "$headers" | grep -qi "access-control-allow-headers"; then
cors_headers_found=true
print_success "Access-Control-Allow-Headers header found"
fi
if echo "$headers" | grep -qi "access-control-allow-methods"; then
cors_methods_found=true
print_success "Access-Control-Allow-Methods header found"
fi
if [[ "$cors_origin_found" == true && "$cors_headers_found" == true && "$cors_methods_found" == true ]]; then
print_success "All required CORS headers present"
return 0
else
print_error "Missing CORS headers"
return 1
fi
}
test_json_structure() {
print_step "Testing NIP-11 JSON structure and required fields"
local response=""
if command -v curl &> /dev/null; then
response=$(curl -s -H "Accept: application/nostr+json" "$RELAY_URL/" 2>/dev/null || echo "")
else
print_error "curl command not found - required for NIP-11 testing"
return 1
fi
if [[ -z "$response" ]]; then
print_error "Empty response received"
return 1
fi
# Validate JSON structure using jq
if ! echo "$response" | jq . >/dev/null 2>&1; then
print_error "Response is not valid JSON"
return 1
fi
print_success "Valid JSON structure confirmed"
# Check for required fields
local required_checks=0
local total_checks=0
# Test name field
((total_checks++))
if echo "$response" | jq -e '.name' >/dev/null 2>&1; then
local name=$(echo "$response" | jq -r '.name')
print_success "Name field present: $name"
((required_checks++))
else
print_warning "Name field missing (optional)"
fi
# Test supported_nips field (required)
((total_checks++))
if echo "$response" | jq -e '.supported_nips' >/dev/null 2>&1; then
local nips=$(echo "$response" | jq -r '.supported_nips | @json')
print_success "Supported NIPs field present: $nips"
((required_checks++))
# Verify NIP-11 is in the supported list
if echo "$response" | jq -e '.supported_nips | contains([11])' >/dev/null 2>&1; then
print_success "NIP-11 correctly listed in supported NIPs"
else
print_warning "NIP-11 not found in supported NIPs list"
fi
else
print_error "Supported NIPs field missing (should be present)"
fi
# Test software field
((total_checks++))
if echo "$response" | jq -e '.software' >/dev/null 2>&1; then
local software=$(echo "$response" | jq -r '.software')
print_success "Software field present: $software"
((required_checks++))
else
print_warning "Software field missing (optional)"
fi
# Test version field
((total_checks++))
if echo "$response" | jq -e '.version' >/dev/null 2>&1; then
local version=$(echo "$response" | jq -r '.version')
print_success "Version field present: $version"
((required_checks++))
else
print_warning "Version field missing (optional)"
fi
# Test limitation object
((total_checks++))
if echo "$response" | jq -e '.limitation' >/dev/null 2>&1; then
print_success "Limitation object present"
((required_checks++))
# Check some common limitation fields
if echo "$response" | jq -e '.limitation.max_message_length' >/dev/null 2>&1; then
local max_msg=$(echo "$response" | jq -r '.limitation.max_message_length')
print_info " max_message_length: $max_msg"
fi
if echo "$response" | jq -e '.limitation.max_subscriptions' >/dev/null 2>&1; then
local max_subs=$(echo "$response" | jq -r '.limitation.max_subscriptions')
print_info " max_subscriptions: $max_subs"
fi
else
print_warning "Limitation object missing (recommended)"
fi
# Test description field
if echo "$response" | jq -e '.description' >/dev/null 2>&1; then
local description=$(echo "$response" | jq -r '.description')
print_success "Description field present: ${description:0:50}..."
else
print_warning "Description field missing (optional)"
fi
print_info "JSON structure validation: $required_checks/$total_checks core fields present"
return 0
}
test_content_type_header() {
print_step "Testing Content-Type header"
local headers=""
if command -v curl &> /dev/null; then
headers=$(curl -s -I -H "Accept: application/nostr+json" "$RELAY_URL/" 2>/dev/null || echo "")
else
print_error "curl command not found - required for NIP-11 testing"
return 1
fi
if echo "$headers" | grep -qi "content-type.*application/nostr+json"; then
print_success "Correct Content-Type header: application/nostr+json"
return 0
else
print_warning "Content-Type header not exactly 'application/nostr+json'"
echo "$headers" | grep -i "content-type" | head -1
return 1
fi
}
test_non_root_path() {
print_step "Testing non-root path (should return 404)"
local http_code=""
if command -v curl &> /dev/null; then
http_code=$(curl -s -o /dev/null -w "%{http_code}" -H "Accept: application/nostr+json" "$RELAY_URL/nonexistent" 2>/dev/null || echo "000")
else
print_error "curl command not found - required for NIP-11 testing"
return 1
fi
if [[ "$http_code" == "404" ]]; then
print_success "HTTP 404 Not Found received for non-root path"
return 0
else
print_error "Expected HTTP 404 for non-root path, got HTTP $http_code"
return 1
fi
}
test_websocket_still_works() {
print_step "Testing that WebSocket functionality still works on same port"
if ! command -v websocat &> /dev/null; then
print_warning "websocat not available - skipping WebSocket test"
return 0
fi
# Try to connect to WebSocket and send a simple REQ
local response=""
response=$(echo '["REQ","test_ws_nip11",{}]' | timeout 3s websocat "$RELAY_WS_URL" 2>/dev/null || echo "Connection failed")
if [[ "$response" == *"Connection failed"* ]]; then
print_error "WebSocket connection failed"
return 1
elif [[ "$response" == *"EOSE"* ]]; then
print_success "WebSocket still functional - received EOSE response"
return 0
else
print_warning "WebSocket response unclear, but connection succeeded"
return 0
fi
}
# Main test function
run_nip11_tests() {
print_header "NIP-11 Relay Information Document Tests"
# Check dependencies
print_step "Checking dependencies..."
if ! command -v curl &> /dev/null; then
print_error "curl command not found - required for NIP-11 HTTP testing"
return 1
fi
if ! command -v jq &> /dev/null; then
print_error "jq command not found - required for JSON validation"
return 1
fi
print_success "All dependencies found"
print_header "PHASE 1: Basic HTTP Functionality"
# Test 1: Correct Accept header
if ! test_http_with_correct_header; then
return 1
fi
# Test 2: Missing Accept header
if ! test_http_without_header; then
return 1
fi
# Test 3: Wrong Accept header
if ! test_http_with_wrong_header; then
return 1
fi
print_header "PHASE 2: HTTP Headers Validation"
# Test 4: CORS headers
if ! test_cors_headers; then
return 1
fi
# Test 5: Content-Type header
if ! test_content_type_header; then
return 1
fi
print_header "PHASE 3: JSON Structure Validation"
# Test 6: JSON structure and required fields
if ! test_json_structure; then
return 1
fi
print_header "PHASE 4: Additional Endpoint Behavior"
# Test 7: Non-root paths
if ! test_non_root_path; then
return 1
fi
# Test 8: WebSocket compatibility
if ! test_websocket_still_works; then
return 1
fi
print_header "PHASE 5: NIP-11 Compliance Summary"
# Final validation - get the actual response and display it
print_step "Displaying complete NIP-11 response..."
local response=""
if command -v curl &> /dev/null; then
response=$(curl -s -H "Accept: application/nostr+json" "$RELAY_URL/" 2>/dev/null || echo "")
if [[ -n "$response" ]] && echo "$response" | jq . >/dev/null 2>&1; then
echo "$response" | jq .
else
print_error "Failed to retrieve or parse final response"
fi
fi
print_success "All NIP-11 tests passed!"
return 0
}
# Main execution
print_header "Starting NIP-11 Relay Information Document Test Suite"
echo
if run_nip11_tests; then
echo
print_success "All NIP-11 tests completed successfully!"
print_info "The C-Relay NIP-11 implementation is fully compliant"
print_info "✅ HTTP endpoint, Accept header validation, CORS, and JSON structure all working"
echo
exit 0
else
echo
print_error "Some NIP-11 tests failed"
exit 1
fi

384
tests/13_nip_test.sh Executable file
View File

@@ -0,0 +1,384 @@
#!/bin/bash
# NIP-13 Proof of Work Validation Test Suite for C Nostr Relay
# Tests PoW validation in the relay's event processing pipeline
# Based on nostr_core_lib/tests/nip13_test.c
set -e # Exit on error
# Color constants
RED='\033[31m'
GREEN='\033[32m'
YELLOW='\033[33m'
BLUE='\033[34m'
BOLD='\033[1m'
RESET='\033[0m'
# Test configuration
RELAY_URL="ws://127.0.0.1:8888"
HTTP_URL="http://127.0.0.1:8888"
TEST_COUNT=0
PASSED_COUNT=0
FAILED_COUNT=0
# Test results tracking
declare -a TEST_RESULTS=()
print_info() {
echo -e "${BLUE}[INFO]${RESET} $1"
}
print_success() {
echo -e "${GREEN}${BOLD}[SUCCESS]${RESET} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${RESET} $1"
}
print_error() {
echo -e "${RED}${BOLD}[ERROR]${RESET} $1"
}
print_test_header() {
TEST_COUNT=$((TEST_COUNT + 1))
echo ""
echo -e "${BOLD}=== TEST $TEST_COUNT: $1 ===${RESET}"
}
record_test_result() {
local test_name="$1"
local result="$2"
local details="$3"
TEST_RESULTS+=("$test_name|$result|$details")
if [ "$result" = "PASS" ]; then
PASSED_COUNT=$((PASSED_COUNT + 1))
print_success "PASS: $test_name"
else
FAILED_COUNT=$((FAILED_COUNT + 1))
print_error "FAIL: $test_name"
if [ -n "$details" ]; then
echo " Details: $details"
fi
fi
}
# Check if relay is running
check_relay_running() {
print_info "Checking if relay is running..."
if ! curl -s -H "Accept: application/nostr+json" "$HTTP_URL/" >/dev/null 2>&1; then
print_error "Relay is not running or not accessible at $HTTP_URL"
print_info "Please start the relay with: ./make_and_restart_relay.sh"
exit 1
fi
print_success "Relay is running and accessible"
}
# Test NIP-11 relay information includes NIP-13
test_nip11_pow_support() {
print_test_header "NIP-11 PoW Support Advertisement"
print_info "Fetching relay information..."
RELAY_INFO=$(curl -s -H "Accept: application/nostr+json" "$HTTP_URL/")
echo "Relay Info Response:"
echo "$RELAY_INFO" | jq '.'
echo ""
# Check if NIP-13 is in supported_nips
if echo "$RELAY_INFO" | jq -e '.supported_nips | index(13)' >/dev/null 2>&1; then
print_success "✓ NIP-13 found in supported_nips array"
NIP13_SUPPORTED=true
else
print_error "✗ NIP-13 not found in supported_nips array"
NIP13_SUPPORTED=false
fi
# Check if min_pow_difficulty is present
MIN_POW_DIFF=$(echo "$RELAY_INFO" | jq -r '.limitation.min_pow_difficulty // "missing"')
if [ "$MIN_POW_DIFF" != "missing" ]; then
print_success "✓ min_pow_difficulty found: $MIN_POW_DIFF"
MIN_POW_PRESENT=true
else
print_error "✗ min_pow_difficulty not found in limitations"
MIN_POW_PRESENT=false
fi
if [ "$NIP13_SUPPORTED" = true ] && [ "$MIN_POW_PRESENT" = true ]; then
record_test_result "NIP-11 PoW Support Advertisement" "PASS" "NIP-13 supported, min_pow_difficulty=$MIN_POW_DIFF"
return 0
else
record_test_result "NIP-11 PoW Support Advertisement" "FAIL" "Missing NIP-13 support or min_pow_difficulty"
return 1
fi
}
# Test event submission without PoW (should be accepted when min_difficulty=0)
test_event_without_pow() {
print_test_header "Event Submission Without PoW (min_difficulty=0)"
# Create a simple event without PoW
print_info "Generating test event without PoW..."
# Use nak to generate a simple event
if ! command -v nak &> /dev/null; then
print_warning "nak command not found - skipping PoW generation tests"
record_test_result "Event Submission Without PoW" "SKIP" "nak not available"
return 0
fi
# Generate event without PoW using direct private key
PRIVATE_KEY="91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe"
EVENT_JSON=$(nak event --sec "$PRIVATE_KEY" -c "Test event without PoW" --ts $(date +%s))
print_info "Generated event:"
echo "$EVENT_JSON" | jq '.'
echo ""
# Send event to relay via WebSocket using websocat
print_info "Sending event to relay..."
# Create EVENT message in Nostr format
EVENT_MESSAGE="[\"EVENT\",$EVENT_JSON]"
# Send to relay and capture response
if command -v websocat &> /dev/null; then
RESPONSE=$(echo "$EVENT_MESSAGE" | timeout 5s websocat "$RELAY_URL" 2>&1 || echo "Connection failed")
print_info "Relay response: $RESPONSE"
if [[ "$RESPONSE" == *"Connection failed"* ]]; then
print_error "✗ Failed to connect to relay"
record_test_result "Event Submission Without PoW" "FAIL" "Connection failed"
return 1
elif [[ "$RESPONSE" == *"true"* ]]; then
print_success "✓ Event without PoW accepted (expected when min_difficulty=0)"
record_test_result "Event Submission Without PoW" "PASS" "Event accepted as expected"
return 0
else
print_error "✗ Event without PoW rejected (unexpected when min_difficulty=0)"
record_test_result "Event Submission Without PoW" "FAIL" "Event rejected: $RESPONSE"
return 1
fi
else
print_error "websocat not found - required for testing"
record_test_result "Event Submission Without PoW" "SKIP" "websocat not available"
return 0
fi
}
# Test event with valid PoW
test_event_with_pow() {
print_test_header "Event Submission With Valid PoW"
if ! command -v nak &> /dev/null; then
print_warning "nak command not found - skipping PoW validation tests"
record_test_result "Event Submission With Valid PoW" "SKIP" "nak not available"
return 0
fi
print_info "Generating event with PoW difficulty 8..."
# Generate event with PoW (difficulty 8 for reasonable test time) using direct private key
PRIVATE_KEY="91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe"
POW_EVENT_JSON=$(nak event --sec "$PRIVATE_KEY" -c "Test event with PoW difficulty 8" --pow 8 --ts $(date +%s))
if [ -z "$POW_EVENT_JSON" ]; then
print_error "Failed to generate PoW event"
record_test_result "Event Submission With Valid PoW" "FAIL" "PoW event generation failed"
return 1
fi
print_info "Generated PoW event:"
echo "$POW_EVENT_JSON" | jq '.'
echo ""
# Extract nonce info for verification
NONCE_TAG=$(echo "$POW_EVENT_JSON" | jq -r '.tags[] | select(.[0] == "nonce") | .[1]' 2>/dev/null || echo "")
TARGET_DIFF=$(echo "$POW_EVENT_JSON" | jq -r '.tags[] | select(.[0] == "nonce") | .[2]' 2>/dev/null || echo "")
if [ -n "$NONCE_TAG" ] && [ -n "$TARGET_DIFF" ]; then
print_info "PoW details: nonce=$NONCE_TAG, target_difficulty=$TARGET_DIFF"
fi
# Send event to relay via WebSocket using websocat
print_info "Sending PoW event to relay..."
# Create EVENT message in Nostr format
POW_EVENT_MESSAGE="[\"EVENT\",$POW_EVENT_JSON]"
# Send to relay and capture response
if command -v websocat &> /dev/null; then
RESPONSE=$(echo "$POW_EVENT_MESSAGE" | timeout 10s websocat "$RELAY_URL" 2>&1 || echo "Connection failed")
print_info "Relay response: $RESPONSE"
if [[ "$RESPONSE" == *"Connection failed"* ]]; then
print_error "✗ Failed to connect to relay"
record_test_result "Event Submission With Valid PoW" "FAIL" "Connection failed"
return 1
elif [[ "$RESPONSE" == *"true"* ]]; then
print_success "✓ Event with valid PoW accepted"
record_test_result "Event Submission With Valid PoW" "PASS" "PoW event accepted"
return 0
else
print_error "✗ Event with valid PoW rejected"
record_test_result "Event Submission With Valid PoW" "FAIL" "PoW event rejected: $RESPONSE"
return 1
fi
else
print_error "websocat not found - required for testing"
record_test_result "Event Submission With Valid PoW" "SKIP" "websocat not available"
return 0
fi
}
# Test relay configuration with environment variables
test_pow_configuration() {
print_test_header "PoW Configuration Via Environment Variables"
print_info "Testing different PoW configurations requires relay restart"
print_info "Current configuration from logs:"
if [ -f "relay.log" ]; then
grep "PoW Configuration:" relay.log | tail -1
else
print_warning "No relay.log found"
fi
# Test current configuration values
RELAY_INFO=$(curl -s -H "Accept: application/nostr+json" "$HTTP_URL/")
MIN_POW_DIFF=$(echo "$RELAY_INFO" | jq -r '.limitation.min_pow_difficulty')
print_info "Current min_pow_difficulty from NIP-11: $MIN_POW_DIFF"
# For now, just verify the configuration is readable
if [ "$MIN_POW_DIFF" != "null" ] && [ "$MIN_POW_DIFF" != "missing" ]; then
print_success "✓ PoW configuration is accessible via NIP-11"
record_test_result "PoW Configuration Via Environment Variables" "PASS" "min_pow_difficulty=$MIN_POW_DIFF"
return 0
else
print_error "✗ PoW configuration not accessible"
record_test_result "PoW Configuration Via Environment Variables" "FAIL" "Cannot read min_pow_difficulty"
return 1
fi
}
# Test NIP-13 reference event validation
test_nip13_reference_event() {
print_test_header "NIP-13 Reference Event Validation"
# This is the official NIP-13 reference event
NIP13_REF_EVENT='{"id":"000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358","pubkey":"a48380f4cfcc1ad5378294fcac36439770f9c878dd880ffa94bb74ea54a6f243","created_at":1651794653,"kind":1,"tags":[["nonce","776797","20"]],"content":"It'\''s just me mining my own business","sig":"284622fc0a3f4f1303455d5175f7ba962a3300d136085b9566801bc2e0699de0c7e31e44c81fb40ad9049173742e904713c3594a1da0fc5d2382a25c11aba977"}'
print_info "Testing NIP-13 reference event from specification:"
echo "$NIP13_REF_EVENT" | jq '.'
echo ""
# Send reference event to relay via WebSocket using websocat
print_info "Sending NIP-13 reference event to relay..."
# Create EVENT message in Nostr format
REF_EVENT_MESSAGE="[\"EVENT\",$NIP13_REF_EVENT]"
# Send to relay and capture response
if command -v websocat &> /dev/null; then
RESPONSE=$(echo "$REF_EVENT_MESSAGE" | timeout 10s websocat "$RELAY_URL" 2>&1 || echo "Connection failed")
print_info "Relay response: $RESPONSE"
if [[ "$RESPONSE" == *"Connection failed"* ]] || [[ -z "$RESPONSE" ]]; then
print_error "✗ Failed to connect to relay or no response"
record_test_result "NIP-13 Reference Event Validation" "FAIL" "Connection failed or timeout"
return 1
elif [[ "$RESPONSE" == *"true"* ]]; then
print_success "✓ NIP-13 reference event accepted"
record_test_result "NIP-13 Reference Event Validation" "PASS" "Reference event accepted"
return 0
else
print_error "✗ NIP-13 reference event rejected"
record_test_result "NIP-13 Reference Event Validation" "FAIL" "Reference event rejected: $RESPONSE"
return 1
fi
else
print_error "websocat not found - required for testing"
record_test_result "NIP-13 Reference Event Validation" "SKIP" "websocat not available"
return 0
fi
}
# Print test summary
print_test_summary() {
echo ""
echo -e "${BOLD}=== TEST SUMMARY ===${RESET}"
echo "Total tests run: $TEST_COUNT"
echo -e "${GREEN}Passed: $PASSED_COUNT${RESET}"
echo -e "${RED}Failed: $FAILED_COUNT${RESET}"
if [ $FAILED_COUNT -gt 0 ]; then
echo ""
echo -e "${RED}${BOLD}Failed tests:${RESET}"
for result in "${TEST_RESULTS[@]}"; do
IFS='|' read -r name status details <<< "$result"
if [ "$status" = "FAIL" ]; then
echo -e " ${RED}$name${RESET}"
if [ -n "$details" ]; then
echo " $details"
fi
fi
done
fi
echo ""
if [ $FAILED_COUNT -eq 0 ]; then
echo -e "${GREEN}${BOLD}🎉 ALL TESTS PASSED!${RESET}"
echo -e "${GREEN}✅ NIP-13 PoW validation is working correctly in the relay${RESET}"
return 0
else
echo -e "${RED}${BOLD}❌ SOME TESTS FAILED${RESET}"
echo "Please review the output above and check relay logs for more details."
return 1
fi
}
# Main test execution
main() {
echo -e "${BOLD}=== NIP-13 Proof of Work Relay Test Suite ===${RESET}"
echo "Testing NIP-13 PoW validation in the C Nostr Relay"
echo "Relay URL: $RELAY_URL"
echo ""
# Check prerequisites
if ! command -v curl &> /dev/null; then
print_error "curl is required but not installed"
exit 1
fi
if ! command -v jq &> /dev/null; then
print_error "jq is required but not installed"
exit 1
fi
if ! command -v websocat &> /dev/null; then
print_warning "websocat not found - WebSocket tests will be skipped"
fi
# Run tests
check_relay_running
test_nip11_pow_support
test_event_without_pow
test_event_with_pow
test_pow_configuration
test_nip13_reference_event
# Print summary
print_test_summary
exit $?
}
# Run main function
main "$@"

539
tests/40_nip_test.sh Executable file
View File

@@ -0,0 +1,539 @@
#!/bin/bash
# NIP-40 Expiration Timestamp Test Suite for C Nostr Relay
# Tests expiration timestamp handling in the relay's event processing pipeline
set -e # Exit on error
# Color constants
RED='\033[31m'
GREEN='\033[32m'
YELLOW='\033[33m'
BLUE='\033[34m'
BOLD='\033[1m'
RESET='\033[0m'
# Test configuration
RELAY_URL="ws://127.0.0.1:8888"
HTTP_URL="http://127.0.0.1:8888"
TEST_COUNT=0
PASSED_COUNT=0
FAILED_COUNT=0
# Test results tracking
declare -a TEST_RESULTS=()
print_info() {
echo -e "${BLUE}[INFO]${RESET} $1"
}
print_success() {
echo -e "${GREEN}${BOLD}[SUCCESS]${RESET} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${RESET} $1"
}
print_error() {
echo -e "${RED}${BOLD}[ERROR]${RESET} $1"
}
print_test_header() {
TEST_COUNT=$((TEST_COUNT + 1))
echo ""
echo -e "${BOLD}=== TEST $TEST_COUNT: $1 ===${RESET}"
}
record_test_result() {
local test_name="$1"
local result="$2"
local details="$3"
TEST_RESULTS+=("$test_name|$result|$details")
if [ "$result" = "PASS" ]; then
PASSED_COUNT=$((PASSED_COUNT + 1))
print_success "PASS: $test_name"
else
FAILED_COUNT=$((FAILED_COUNT + 1))
print_error "FAIL: $test_name"
if [ -n "$details" ]; then
echo " Details: $details"
fi
fi
}
# Check if relay is running
check_relay_running() {
print_info "Checking if relay is running..."
if ! curl -s -H "Accept: application/nostr+json" "$HTTP_URL/" >/dev/null 2>&1; then
print_error "Relay is not running or not accessible at $HTTP_URL"
print_info "Please start the relay with: ./make_and_restart_relay.sh"
exit 1
fi
print_success "Relay is running and accessible"
}
# Test NIP-11 relay information includes NIP-40
test_nip11_expiration_support() {
print_test_header "NIP-11 Expiration Support Advertisement"
print_info "Fetching relay information..."
RELAY_INFO=$(curl -s -H "Accept: application/nostr+json" "$HTTP_URL/")
echo "Relay Info Response:"
echo "$RELAY_INFO" | jq '.'
echo ""
# Check if NIP-40 is in supported_nips
if echo "$RELAY_INFO" | jq -e '.supported_nips | index(40)' >/dev/null 2>&1; then
print_success "✓ NIP-40 found in supported_nips array"
NIP40_SUPPORTED=true
else
print_error "✗ NIP-40 not found in supported_nips array"
NIP40_SUPPORTED=false
fi
if [ "$NIP40_SUPPORTED" = true ]; then
record_test_result "NIP-11 Expiration Support Advertisement" "PASS" "NIP-40 advertised in relay info"
return 0
else
record_test_result "NIP-11 Expiration Support Advertisement" "FAIL" "NIP-40 not advertised"
return 1
fi
}
# Helper function to create event with expiration tag
create_event_with_expiration() {
local content="$1"
local expiration_timestamp="$2"
local private_key="91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe"
if ! command -v nak &> /dev/null; then
echo ""
return 1
fi
# Create event with expiration tag
nak event --sec "$private_key" -c "$content" -t "expiration=$expiration_timestamp" --ts $(date +%s)
}
# Helper function to send event and check response
send_event_and_check() {
local event_json="$1"
local expected_result="$2" # "accept" or "reject"
local description="$3"
if [ -z "$event_json" ]; then
return 1
fi
# Create EVENT message
local event_message="[\"EVENT\",$event_json]"
# Send to relay
if command -v websocat &> /dev/null; then
local response=$(echo "$event_message" | timeout 5s websocat "$RELAY_URL" 2>&1 || echo "Connection failed")
print_info "Relay response: $response"
if [[ "$response" == *"Connection failed"* ]]; then
print_error "✗ Failed to connect to relay"
return 1
elif [[ "$expected_result" == "accept" && "$response" == *"true"* ]]; then
print_success "$description accepted as expected"
return 0
elif [[ "$expected_result" == "reject" && "$response" == *"false"* ]]; then
print_success "$description rejected as expected"
return 0
elif [[ "$expected_result" == "accept" && "$response" == *"false"* ]]; then
print_error "$description unexpectedly rejected: $response"
return 1
elif [[ "$expected_result" == "reject" && "$response" == *"true"* ]]; then
print_error "$description unexpectedly accepted: $response"
return 1
else
print_warning "? Unclear response for $description: $response"
return 1
fi
else
print_error "websocat not found - required for testing"
return 1
fi
}
# Test event without expiration tag
test_event_without_expiration() {
print_test_header "Event Submission Without Expiration Tag"
if ! command -v nak &> /dev/null; then
print_warning "nak command not found - skipping expiration tests"
record_test_result "Event Submission Without Expiration Tag" "SKIP" "nak not available"
return 0
fi
print_info "Creating event without expiration tag..."
local private_key="91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe"
local event_json=$(nak event --sec "$private_key" -c "Test event without expiration" --ts $(date +%s))
print_info "Generated event:"
echo "$event_json" | jq '.'
echo ""
if send_event_and_check "$event_json" "accept" "Event without expiration tag"; then
record_test_result "Event Submission Without Expiration Tag" "PASS" "Non-expiring event accepted"
return 0
else
record_test_result "Event Submission Without Expiration Tag" "FAIL" "Non-expiring event handling failed"
return 1
fi
}
# Test event with future expiration (should be accepted)
test_event_with_future_expiration() {
print_test_header "Event Submission With Future Expiration"
if ! command -v nak &> /dev/null; then
record_test_result "Event Submission With Future Expiration" "SKIP" "nak not available"
return 0
fi
print_info "Creating event with future expiration (1 hour from now)..."
local future_timestamp=$(($(date +%s) + 3600)) # 1 hour from now
local event_json=$(create_event_with_expiration "Test event expiring in 1 hour" "$future_timestamp")
if [ -z "$event_json" ]; then
record_test_result "Event Submission With Future Expiration" "FAIL" "Failed to create event"
return 1
fi
print_info "Generated event (expires at $future_timestamp):"
echo "$event_json" | jq '.'
echo ""
if send_event_and_check "$event_json" "accept" "Event with future expiration"; then
record_test_result "Event Submission With Future Expiration" "PASS" "Future-expiring event accepted"
return 0
else
record_test_result "Event Submission With Future Expiration" "FAIL" "Future-expiring event rejected"
return 1
fi
}
# Test event with past expiration (should be rejected in strict mode)
test_event_with_past_expiration() {
print_test_header "Event Submission With Past Expiration"
if ! command -v nak &> /dev/null; then
record_test_result "Event Submission With Past Expiration" "SKIP" "nak not available"
return 0
fi
print_info "Creating event with past expiration (1 hour ago)..."
local past_timestamp=$(($(date +%s) - 3600)) # 1 hour ago
local event_json=$(create_event_with_expiration "Test event expired 1 hour ago" "$past_timestamp")
if [ -z "$event_json" ]; then
record_test_result "Event Submission With Past Expiration" "FAIL" "Failed to create event"
return 1
fi
print_info "Generated event (expired at $past_timestamp):"
echo "$event_json" | jq '.'
echo ""
# In strict mode (default), this should be rejected
if send_event_and_check "$event_json" "reject" "Event with past expiration"; then
record_test_result "Event Submission With Past Expiration" "PASS" "Expired event correctly rejected in strict mode"
return 0
else
record_test_result "Event Submission With Past Expiration" "FAIL" "Expired event handling failed"
return 1
fi
}
# Test event with expiration within grace period
test_event_within_grace_period() {
print_test_header "Event Submission Within Grace Period"
if ! command -v nak &> /dev/null; then
record_test_result "Event Submission Within Grace Period" "SKIP" "nak not available"
return 0
fi
print_info "Creating event with expiration within grace period (2 minutes ago, grace period is 5 minutes)..."
local grace_timestamp=$(($(date +%s) - 120)) # 2 minutes ago (within 5 minute grace period)
local event_json=$(create_event_with_expiration "Test event within grace period" "$grace_timestamp")
if [ -z "$event_json" ]; then
record_test_result "Event Submission Within Grace Period" "FAIL" "Failed to create event"
return 1
fi
print_info "Generated event (expired at $grace_timestamp, within grace period):"
echo "$event_json" | jq '.'
echo ""
# Should be accepted due to grace period
if send_event_and_check "$event_json" "accept" "Event within grace period"; then
record_test_result "Event Submission Within Grace Period" "PASS" "Event within grace period accepted"
return 0
else
record_test_result "Event Submission Within Grace Period" "FAIL" "Grace period handling failed"
return 1
fi
}
# Test event filtering in subscriptions
test_expiration_filtering_in_subscriptions() {
print_test_header "Expiration Filtering in Subscriptions"
if ! command -v nak &> /dev/null || ! command -v websocat &> /dev/null; then
record_test_result "Expiration Filtering in Subscriptions" "SKIP" "Required tools not available"
return 0
fi
print_info "Setting up test events for subscription filtering..."
# First, create a few events with different expiration times
local private_key="91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe"
# Event 1: No expiration (should be returned)
local event1=$(nak event --sec "$private_key" -c "Event without expiration for filtering test" --ts $(date +%s))
# Event 2: Future expiration (should be returned)
local future_timestamp=$(($(date +%s) + 1800)) # 30 minutes from now
local event2=$(create_event_with_expiration "Event with future expiration for filtering test" "$future_timestamp")
# Event 3: Past expiration (should NOT be returned if filtering is enabled)
local past_timestamp=$(($(date +%s) - 3600)) # 1 hour ago
local event3=$(create_event_with_expiration "Event with past expiration for filtering test" "$past_timestamp")
print_info "Publishing test events..."
# Note: We expect event3 to be rejected on submission in strict mode,
# so we'll create it with a slightly more recent expiration that might get through
local recent_past=$(($(date +%s) - 600)) # 10 minutes ago (outside grace period)
local event3_recent=$(create_event_with_expiration "Recently expired event for filtering test" "$recent_past")
# Try to submit all events (some may be rejected)
echo "[\"EVENT\",$event1]" | timeout 3s websocat "$RELAY_URL" >/dev/null 2>&1 || true
echo "[\"EVENT\",$event2]" | timeout 3s websocat "$RELAY_URL" >/dev/null 2>&1 || true
echo "[\"EVENT\",$event3_recent]" | timeout 3s websocat "$RELAY_URL" >/dev/null 2>&1 || true
sleep 2 # Let events settle
print_info "Testing subscription filtering..."
# Create subscription for recent events
local req_message='["REQ","filter_test",{"kinds":[1],"limit":10}]'
local response=$(echo -e "$req_message\n[\"CLOSE\",\"filter_test\"]" | timeout 5s websocat "$RELAY_URL" 2>/dev/null || echo "")
print_info "Subscription response:"
echo "$response"
echo ""
# Count events that contain our test content
local no_exp_count=0
local future_exp_count=0
local past_exp_count=0
if echo "$response" | grep -q "Event without expiration for filtering test"; then
no_exp_count=1
print_success "✓ Event without expiration found in subscription results"
fi
if echo "$response" | grep -q "Event with future expiration for filtering test"; then
future_exp_count=1
print_success "✓ Event with future expiration found in subscription results"
fi
if echo "$response" | grep -q "Recently expired event for filtering test"; then
past_exp_count=1
print_warning "✗ Recently expired event found in subscription results (should be filtered)"
else
print_success "✓ Recently expired event properly filtered from subscription results"
fi
# Evaluate results
local expected_events=$((no_exp_count + future_exp_count))
if [ $expected_events -ge 1 ] && [ $past_exp_count -eq 0 ]; then
record_test_result "Expiration Filtering in Subscriptions" "PASS" "Expired events properly filtered from subscriptions"
return 0
else
record_test_result "Expiration Filtering in Subscriptions" "FAIL" "Expiration filtering not working properly in subscriptions"
return 1
fi
}
# Test malformed expiration tags
test_malformed_expiration_tags() {
print_test_header "Handling of Malformed Expiration Tags"
if ! command -v nak &> /dev/null; then
record_test_result "Handling of Malformed Expiration Tags" "SKIP" "nak not available"
return 0
fi
print_info "Testing events with malformed expiration tags..."
local private_key="91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe"
# Test 1: Non-numeric expiration value
local event1=$(nak event --sec "$private_key" -c "Event with non-numeric expiration" -t "expiration=not_a_number" --ts $(date +%s))
# Test 2: Empty expiration value
local event2=$(nak event --sec "$private_key" -c "Event with empty expiration" -t "expiration=" --ts $(date +%s))
print_info "Testing non-numeric expiration value..."
if send_event_and_check "$event1" "accept" "Event with non-numeric expiration (should be treated as no expiration)"; then
print_success "✓ Non-numeric expiration handled gracefully"
malformed_test1=true
else
malformed_test1=false
fi
print_info "Testing empty expiration value..."
if send_event_and_check "$event2" "accept" "Event with empty expiration (should be treated as no expiration)"; then
print_success "✓ Empty expiration handled gracefully"
malformed_test2=true
else
malformed_test2=false
fi
if [ "$malformed_test1" = true ] && [ "$malformed_test2" = true ]; then
record_test_result "Handling of Malformed Expiration Tags" "PASS" "Malformed expiration tags handled gracefully"
return 0
else
record_test_result "Handling of Malformed Expiration Tags" "FAIL" "Malformed expiration tag handling failed"
return 1
fi
}
# Test configuration via environment variables
test_expiration_configuration() {
print_test_header "Expiration Configuration Via Environment Variables"
print_info "Testing expiration configuration from relay logs..."
if [ -f "relay.log" ]; then
print_info "Current configuration from logs:"
grep "Expiration Configuration:" relay.log | tail -1 || print_warning "No expiration configuration found in logs"
else
print_warning "No relay.log found"
fi
# The relay should be running with default configuration
print_info "Default configuration should be:"
print_info " enabled=true"
print_info " strict_mode=true (rejects expired events on submission)"
print_info " filter_responses=true (filters expired events from responses)"
print_info " grace_period=300 seconds (5 minutes)"
# Test current behavior matches expected default configuration
print_info "Configuration test based on observed behavior:"
# Check if NIP-40 is advertised (indicates enabled=true)
if curl -s -H "Accept: application/nostr+json" "$HTTP_URL/" | jq -e '.supported_nips | index(40)' >/dev/null 2>&1; then
print_success "✓ NIP-40 support advertised (enabled=true)"
config_test=true
else
print_error "✗ NIP-40 not advertised (may be disabled)"
config_test=false
fi
if [ "$config_test" = true ]; then
record_test_result "Expiration Configuration Via Environment Variables" "PASS" "Expiration configuration is accessible and working"
return 0
else
record_test_result "Expiration Configuration Via Environment Variables" "FAIL" "Expiration configuration issues detected"
return 1
fi
}
# Print test summary
print_test_summary() {
echo ""
echo -e "${BOLD}=== TEST SUMMARY ===${RESET}"
echo "Total tests run: $TEST_COUNT"
echo -e "${GREEN}Passed: $PASSED_COUNT${RESET}"
echo -e "${RED}Failed: $FAILED_COUNT${RESET}"
if [ $FAILED_COUNT -gt 0 ]; then
echo ""
echo -e "${RED}${BOLD}Failed tests:${RESET}"
for result in "${TEST_RESULTS[@]}"; do
IFS='|' read -r name status details <<< "$result"
if [ "$status" = "FAIL" ]; then
echo -e " ${RED}$name${RESET}"
if [ -n "$details" ]; then
echo " $details"
fi
fi
done
fi
echo ""
if [ $FAILED_COUNT -eq 0 ]; then
echo -e "${GREEN}${BOLD}🎉 ALL TESTS PASSED!${RESET}"
echo -e "${GREEN}✅ NIP-40 Expiration Timestamp support is working correctly in the relay${RESET}"
return 0
else
echo -e "${RED}${BOLD}❌ SOME TESTS FAILED${RESET}"
echo "Please review the output above and check relay logs for more details."
return 1
fi
}
# Main test execution
main() {
echo -e "${BOLD}=== NIP-40 Expiration Timestamp Relay Test Suite ===${RESET}"
echo "Testing NIP-40 Expiration Timestamp support in the C Nostr Relay"
echo "Relay URL: $RELAY_URL"
echo ""
# Check prerequisites
if ! command -v curl &> /dev/null; then
print_error "curl is required but not installed"
exit 1
fi
if ! command -v jq &> /dev/null; then
print_error "jq is required but not installed"
exit 1
fi
if ! command -v websocat &> /dev/null; then
print_warning "websocat not found - WebSocket tests will be skipped"
fi
if ! command -v nak &> /dev/null; then
print_warning "nak not found - Event generation tests will be skipped"
print_info "Install with: go install github.com/fiatjaf/nak@latest"
fi
# Run tests
check_relay_running
test_nip11_expiration_support
test_event_without_expiration
test_event_with_future_expiration
test_event_with_past_expiration
test_event_within_grace_period
test_expiration_filtering_in_subscriptions
test_malformed_expiration_tags
test_expiration_configuration
# Print summary
print_test_summary
exit $?
}
# Run main function
main "$@"