initial commit

This commit is contained in:
Timur Gordon
2025-07-29 01:15:23 +02:00
commit 7d7ff0f0ab
108 changed files with 24713 additions and 0 deletions

BIN
interfaces/websocket/server/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,10 @@
# Webhook Configuration
# Copy this file to .env and set your actual webhook secrets
# Stripe webhook endpoint secret
# Get this from your Stripe dashboard under Webhooks
STRIPE_WEBHOOK_SECRET=whsec_your_stripe_webhook_secret_here
# iDenfy webhook endpoint secret
# Get this from your iDenfy dashboard under Webhooks
IDENFY_WEBHOOK_SECRET=your_idenfy_webhook_secret_here

View File

@@ -0,0 +1,3 @@
/target
file:memdb_test_server*
*.pem

2775
interfaces/websocket/server/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,78 @@
[package]
name = "hero_websocket_server"
version = "0.1.0"
edition = "2021"
[lib]
name = "hero_websocket_server"
path = "src/lib.rs"
[[bin]]
name = "hero_websocket_server"
path = "cmd/main.rs"
[[example]]
name = "wss_basic_example"
path = "../../examples/wss_basic_example.rs"
[[example]]
name = "wss_auth_example"
path = "../../examples/wss_auth_example.rs"
required-features = ["auth"]
[[example]]
name = "wss_test_client"
path = "../../examples/wss_test_client.rs"
[[example]]
name = "wss_server"
path = "../../examples/wss_demo/wss_server.rs"
required-features = ["auth"]
[dependencies]
rustls = "0.23.5"
rustls-pemfile = "2.1.2"
actix-web = { workspace = true, features = ["rustls-0_23"] }
actix-web-actors = { workspace = true }
actix = { workspace = true }
env_logger = { workspace = true }
log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
redis = { workspace = true }
uuid = { workspace = true }
tokio = { workspace = true }
chrono = { workspace = true }
rhai_dispatcher = { path = "../../../../rhailib/src/dispatcher" } # Corrected relative path
thiserror = { workspace = true }
heromodels = { path = "../../../../db/heromodels" }
# Webhook dependencies
hmac = "0.12"
sha2 = "0.10"
dotenv = "0.15"
bytes = "1.0"
hex = { workspace = true }
# Authentication dependencies (optional)
secp256k1 = { workspace = true, optional = true }
sha3 = { workspace = true, optional = true }
rand = { workspace = true, optional = true }
once_cell = { workspace = true }
clap = { workspace = true }
# Optional features for authentication
[features]
default = []
auth = ["secp256k1", "sha3", "rand"]
[dev-dependencies]
redis = { version = "0.23.0", features = ["tokio-comp"] }
uuid = { version = "1.2.2", features = ["v4"] }
tokio-tungstenite = { version = "0.19.0", features = ["native-tls"] }
futures-util = { workspace = true }
url = { workspace = true }
heromodels = { path = "../../../../db/heromodels" }
tokio = { workspace = true, features = ["full"] }
native-tls = "0.2"

View File

@@ -0,0 +1,76 @@
# `server`: The Circles WebSocket Server
The `server` crate provides a secure, high-performance WebSocket server built with `Actix`. It is the core backend component of the `circles` ecosystem, responsible for handling client connections, processing JSON-RPC requests, and executing Rhai scripts in a secure manner.
## Features
- **`Actix` Framework**: Built on `Actix`, a powerful and efficient actor-based web framework.
- **WebSocket Management**: Uses `actix-web-actors` to manage each client connection in its own isolated actor (`CircleWs`), ensuring robust and concurrent session handling.
- **JSON-RPC 2.0 API**: Implements a JSON-RPC 2.0 API for all client-server communication. The API is formally defined in the root [openrpc.json](../../openrpc.json) file.
- **Secure Authentication**: Features a built-in `secp256k1` signature-based authentication system to protect sensitive endpoints.
- **Stateful Session Management**: The `CircleWs` actor maintains the authentication state for each client, granting or denying access to protected methods like `play`.
- **Webhook Integration**: Supports HTTP webhook endpoints for external services (Stripe, iDenfy) with signature verification and script execution capabilities.
## Core Components
### `spawn_circle_server`
This is the main entry point function for the server. It configures and starts the `Actix` HTTP server and sets up the WebSocket route with path-based routing (`/{circle_pk}`).
### `CircleWs` Actor
This `Actix` actor is the heart of the server's session management. A new instance of `CircleWs` is created for each client that connects. Its responsibilities include:
- Handling the WebSocket connection lifecycle.
- Parsing incoming JSON-RPC messages.
- Managing the authentication state of the session (i.e., whether the client is authenticated or not).
- Dispatching requests to the appropriate handlers (`fetch_nonce`, `authenticate`, and `play`).
## Authentication
The server provides a robust authentication mechanism to ensure that only authorized clients can execute scripts. The entire flow is handled over the WebSocket connection using two dedicated JSON-RPC methods:
1. **`fetch_nonce`**: The client requests a unique, single-use nonce (a challenge) from the server.
2. **`authenticate`**: The client sends back the nonce signed with its private key. The `CircleWs` actor verifies the signature to confirm the client's identity.
For a more detailed breakdown of the authentication architecture, please see the [ARCHITECTURE.md](docs/ARCHITECTURE.md) file.
## Webhook Integration
The server also provides HTTP webhook endpoints for external services alongside the WebSocket functionality:
- **Stripe Webhooks**: `POST /webhooks/stripe/{circle_pk}` - Handles Stripe payment events
- **iDenfy Webhooks**: `POST /webhooks/idenfy/{circle_pk}` - Handles iDenfy KYC verification events
### Webhook Features
- **Signature Verification**: All webhooks use HMAC signature verification for security
- **Script Execution**: Webhook events trigger Rhai script execution via the same Redis-based system
- **Type Safety**: Webhook payload types are defined in the `heromodels` library for reusability
- **Modular Architecture**: Separate handlers for each webhook provider with common utilities
For detailed webhook architecture and configuration, see [WEBHOOK_ARCHITECTURE.md](WEBHOOK_ARCHITECTURE.md).
## How to Run
### As a Library
The `server` is designed to be used as a library by the `launcher`, which is responsible for spawning a single multi-circle server instance that can handle multiple circles via path-based routing.
To run the server via the launcher with circle public keys:
```bash
cargo run --package launcher -- -k <circle_public_key1> -k <circle_public_key2> [options]
```
The launcher will start a single `server` instance that can handle multiple circles through path-based WebSocket connections at `/{circle_pk}`.
### Standalone Binary
A standalone binary is also available for development and testing purposes. See [`cmd/README.md`](cmd/README.md) for detailed usage instructions.
```bash
# Basic standalone server
cargo run
# With authentication and TLS
cargo run -- --auth --tls --cert cert.pem --key key.pem
```

View File

@@ -0,0 +1,142 @@
# Circles WebSocket Server Binary
A command-line WebSocket server for hosting Circles with authentication and TLS support.
## Binary: Server
### Installation
Build the binary:
```bash
cargo build --release
```
### Usage
```bash
# Basic usage - starts server on localhost:8443
cargo run
# Custom host and port
cargo run -- --host 0.0.0.0 --port 9000
# Enable authentication
cargo run -- --auth
# Enable TLS/WSS with certificates
cargo run -- --tls --cert /path/to/cert.pem --key /path/to/key.pem
# Use separate TLS port
cargo run -- --tls --cert cert.pem --key key.pem --tls-port 8444
# Custom Redis URL
cargo run -- --redis-url redis://localhost:6379/1
# Increase verbosity
cargo run -- -v # Debug logging
cargo run -- -vv # Full debug logging
cargo run -- -vvv # Trace logging
```
### Command-Line Options
| Option | Short | Default | Description |
|--------|-------|---------|-------------|
| `--host` | `-H` | `127.0.0.1` | Server bind address |
| `--port` | `-p` | `8443` | Server port |
| `--redis-url` | | `redis://127.0.0.1/` | Redis connection URL |
| `--auth` | | `false` | Enable secp256k1 authentication |
| `--tls` | | `false` | Enable TLS/WSS support |
| `--cert` | | | Path to TLS certificate file (required with --tls) |
| `--key` | | | Path to TLS private key file (required with --tls) |
| `--tls-port` | | | Separate port for TLS connections |
| `--verbose` | `-v` | | Increase verbosity (stackable) |
### Configuration Examples
#### Development Server
```bash
# Simple development server
cargo run
# Development with authentication
cargo run -- --auth
```
#### Production Server
```bash
# Production with TLS and authentication
cargo run -- \
--host 0.0.0.0 \
--port 8080 \
--tls \
--tls-port 8443 \
--cert /etc/ssl/certs/circles.pem \
--key /etc/ssl/private/circles.key \
--auth \
--redis-url redis://redis-server:6379/0
```
#### Custom Redis Configuration
```bash
# Connect to remote Redis with authentication
cargo run -- --redis-url redis://username:password@redis.example.com:6379/2
```
### Logging Levels
The server supports multiple verbosity levels:
- **Default** (`cargo run`): Shows only warnings and circle_ws_lib info
- **Debug** (`-v`): Shows debug info for circle_ws_lib, info for actix
- **Full Debug** (`-vv`): Shows debug for all components
- **Trace** (`-vvv+`): Shows trace-level logging for everything
### TLS/SSL Configuration
When using `--tls`, you must provide both certificate and key files:
```bash
# Generate self-signed certificate for testing
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
# Run server with TLS
cargo run -- --tls --cert cert.pem --key key.pem
```
### Authentication
When `--auth` is enabled, clients must complete secp256k1 authentication:
1. Client connects to WebSocket
2. Server sends authentication challenge
3. Client signs challenge with private key
4. Server verifies signature and grants access
### Redis Integration
The server uses Redis for:
- Session management
- Message persistence
- Cross-instance communication (in clustered deployments)
Supported Redis URL formats:
- `redis://localhost/` - Local Redis, default database
- `redis://localhost:6379/1` - Local Redis, database 1
- `redis://user:pass@host:port/db` - Authenticated Redis
- `rediss://host:port/` - Redis with TLS
### Error Handling
The server provides clear error messages for common configuration issues:
- Missing TLS certificate or key files
- Invalid Redis connection URLs
- Port binding failures
- Authentication setup problems
### Dependencies
- `actix-web`: Web server framework
- `tokio-tungstenite`: WebSocket implementation
- `redis`: Redis client
- `rustls`: TLS implementation
- `clap`: Command-line argument parsing

View File

@@ -0,0 +1,150 @@
use hero_websocket_server::{ServerBuilder, TlsConfigError};
use clap::Parser;
use dotenv::dotenv;
use log::info;
#[derive(Parser, Debug)]
#[clap(author, version, about, long_about = None)]
struct Args {
#[clap(short = 'H', long, value_parser, default_value = "127.0.0.1")]
host: String,
#[clap(short, long, value_parser, default_value_t = 8443)]
port: u16,
#[clap(long, value_parser, default_value = "redis://127.0.0.1/")]
redis_url: String,
#[clap(long, help = "Enable authentication")]
auth: bool,
#[clap(long, help = "Enable TLS/WSS")]
tls: bool,
#[clap(long, value_parser, help = "Path to TLS certificate file")]
cert: Option<String>,
#[clap(long, value_parser, help = "Path to TLS private key file")]
key: Option<String>,
#[clap(long, value_parser, help = "Separate port for TLS connections")]
tls_port: Option<u16>,
#[clap(short, long, action = clap::ArgAction::Count, help = "Increase verbosity (-v for debug, -vv for trace)")]
verbose: u8,
#[clap(long, help = "Remove timestamps from log output")]
no_timestamp: bool,
#[clap(long, help = "Enable webhook handling")]
webhooks: bool,
#[clap(long, value_parser, help = "Worker ID for the server")]
worker_id: String,
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let args = Args::parse();
// Configure logging based on verbosity level
let log_config = match args.verbose {
0 => {
// Default: suppress actix server logs, show only hero_websocket_server info and above
"warn,hero_websocket_server=info"
}
1 => {
// -v: show debug for hero_websocket_server, info for actix
"info,hero_websocket_server=debug,actix_server=info"
}
2 => {
// -vv: show debug for everything
"debug"
}
_ => {
// -vvv and above: show trace for everything
"trace"
}
};
std::env::set_var("RUST_LOG", log_config);
// Configure env_logger with or without timestamps
if args.no_timestamp {
env_logger::Builder::from_default_env()
.format_timestamp(None)
.init();
} else {
env_logger::init();
}
// Validate TLS configuration
if args.tls && (args.cert.is_none() || args.key.is_none()) {
eprintln!("Error: TLS is enabled but certificate or key path is missing");
eprintln!("Use --cert and --key to specify certificate and key files");
std::process::exit(1);
}
let mut builder = ServerBuilder::new()
.host(args.host.clone())
.port(args.port)
.redis_url(args.redis_url.clone())
.worker_id(args.worker_id.clone());
if args.auth {
builder = builder.with_auth();
}
if args.tls {
if let (Some(cert), Some(key)) = (args.cert.clone(), args.key.clone()) {
builder = builder.with_tls(cert, key);
} else {
eprintln!("Error: TLS is enabled but --cert or --key is missing.");
std::process::exit(1);
}
}
if let Some(tls_port) = args.tls_port {
builder = builder.with_tls_port(tls_port);
}
if args.webhooks {
builder = builder.with_webhooks();
}
let server = match builder.build() {
Ok(server) => server,
Err(e) => {
eprintln!("Error building server: {}", e);
std::process::exit(1);
}
};
println!("🚀 Starting Circles WebSocket Server");
println!("📋 Configuration:");
println!(" Host: {}", args.host);
println!(" Port: {}", args.port);
if let Some(tls_port) = args.tls_port {
println!(" TLS Port: {}", tls_port);
}
println!(" Authentication: {}", if args.auth { "ENABLED" } else { "DISABLED" });
println!(" TLS/WSS: {}", if args.tls { "ENABLED" } else { "DISABLED" });
println!(" Webhooks: {}", if args.webhooks { "ENABLED" } else { "DISABLED" });
if args.tls {
if let (Some(cert), Some(key)) = (&args.cert, &args.key) {
println!(" Certificate: {}", cert);
println!(" Private Key: {}", key);
}
}
if args.webhooks {
println!(" Webhook secrets loaded from environment variables:");
println!(" - STRIPE_WEBHOOK_SECRET");
println!(" - IDENFY_WEBHOOK_SECRET");
}
println!();
let (server_task, _server_handle) = server.spawn_circle_server()?;
server_task.await?
}

View File

@@ -0,0 +1,133 @@
# `server` Architecture
This document provides a detailed look into the internal architecture of the `server` crate, focusing on its `Actix`-based design, the structure of the authentication service, and the request lifecycle.
## 1. Core Design: The `Actix` Actor System
The `server` is built around the `Actix` actor framework, which allows for highly concurrent and stateful handling of network requests. The key components of this design are:
- **`HttpServer`**: The main `Actix` server instance that listens for incoming TCP connections.
- **`App`**: The application factory that defines the routes for the server.
- **`CircleWs` Actor**: A dedicated actor that is spawned for each individual WebSocket connection. This is the cornerstone of the server's design, as it allows each client session to be managed in an isolated, stateful manner.
When a client connects to the `/{circle_pk}` endpoint, the `HttpServer` upgrades the connection to a WebSocket and spawns a new `CircleWs` actor to handle it. The circle public key is extracted from the URL path to identify which circle the client wants to connect to. All further communication with that client, including the entire authentication flow, is then processed by this specific actor instance.
## 2. Module Structure
The `server` crate is organized into the following key modules:
- **`lib.rs`**: The main library file that contains the `spawn_circle_server` function, which sets up and runs the `Actix` server. It also defines the `CircleWs` actor and its message handling logic for all JSON-RPC methods.
- **`auth/`**: This module encapsulates all the logic related to the `secp256k1` authentication system.
- **`signature_verifier.rs`**: A self-contained utility module that provides the `verify_signature` function. This function performs the core cryptographic verification of the client's signed nonce.
- **`types.rs`**: Defines the data structures used within the authentication service.
- **`webhook/`**: This module provides HTTP webhook handling capabilities for external services.
- **`mod.rs`**: Main webhook module with route configuration and exports.
- **`handlers/`**: Contains individual webhook handlers for different providers (Stripe, iDenfy).
- **`verifiers.rs`**: Signature verification utilities for webhook authenticity.
- **`types.rs`**: Local webhook types (configuration, errors, verification results).
## 3. Request Lifecycle and Authentication Flow
The diagram below illustrates the flow of a typical client interaction. The entire process, from fetching a nonce to executing a protected command, occurs over the WebSocket connection and is handled by the `CircleWs` actor.
```mermaid
sequenceDiagram
participant Client
participant ActixHttpServer as HttpServer
participant CircleWsActor as CircleWs Actor
participant SignatureVerifier as auth::signature_verifier
Client->>+ActixHttpServer: Establishes WebSocket connection
ActixHttpServer->>ActixHttpServer: Spawns a new CircleWsActor
ActixHttpServer-->>-Client: WebSocket connection established
Note over CircleWsActor: Session created, authenticated = false
Client->>+CircleWsActor: Sends "fetch_nonce" JSON-RPC message
CircleWsActor->>CircleWsActor: Generate and store nonce for pubkey
CircleWsActor-->>-Client: Returns nonce in JSON-RPC response
Client->>Client: Signs nonce with private key
Client->>+CircleWsActor: Sends "authenticate" JSON-RPC message
CircleWsActor->>+SignatureVerifier: verify_signature(pubkey, nonce, signature)
SignatureVerifier-->>-CircleWsActor: Returns verification result
alt Signature is Valid
CircleWsActor->>CircleWsActor: Set session state: authenticated = true
CircleWsActor-->>-Client: Returns success response
else Signature is Invalid
CircleWsActor-->>-Client: Returns error response
end
Note over CircleWsActor: Client is now authenticated
Client->>+CircleWsActor: Sends "play" JSON-RPC message
CircleWsActor->>CircleWsActor: Check if authenticated
alt Is Authenticated
CircleWsActor->>CircleWsActor: Get public key from authenticated connections map
CircleWsActor->>CircleWsActor: Execute Rhai script with public key
CircleWsActor-->>-Client: Returns script result
else Is Not Authenticated
CircleWsActor-->>-Client: Returns "Authentication Required" error
end
```
This architecture ensures a clear separation of concerns and a unified communication protocol:
- The `HttpServer` handles connection management.
- The `CircleWs` actor manages the entire session lifecycle, including state and all API logic.
- The `auth` module provides a self-contained, reusable signature verification utility.
## 4. Webhook Integration Architecture
In addition to WebSocket connections, the server supports HTTP webhook endpoints for external services. This integration runs alongside the WebSocket functionality without interference.
### Webhook Request Flow
```mermaid
sequenceDiagram
participant WS as Webhook Service
participant HS as HttpServer
participant WH as Webhook Handler
participant WV as Webhook Verifier
participant RC as RhaiDispatcher
participant Redis as Redis
WS->>+HS: POST /webhooks/{provider}/{circle_pk}
HS->>+WH: Route to appropriate handler
WH->>WH: Extract circle_pk and signature
WH->>+WV: Verify webhook signature
WV->>WV: HMAC verification with provider secret
WV-->>-WH: Verification result + caller_id
alt Signature Valid
WH->>WH: Parse webhook payload (heromodels types)
WH->>+RC: Create RhaiDispatcher with caller_id
RC->>+Redis: Execute webhook script
Redis-->>-RC: Script result
RC-->>-WH: Execution result
WH-->>-HS: HTTP 200 OK
else Signature Invalid
WH-->>-HS: HTTP 401 Unauthorized
end
HS-->>-WS: HTTP Response
```
### Key Webhook Components
- **Modular Handlers**: Separate handlers for each webhook provider (Stripe, iDenfy)
- **Signature Verification**: HMAC-based verification using provider-specific secrets
- **Type Safety**: Webhook payload types defined in `heromodels` library for reusability
- **Script Integration**: Uses the same Redis-based Rhai execution system as WebSocket connections
- **Isolated Processing**: Webhook processing doesn't affect WebSocket connections
### Webhook vs WebSocket Comparison
| Aspect | WebSocket | Webhook |
|--------|-----------|---------|
| **Connection Type** | Persistent, bidirectional | HTTP request/response |
| **Authentication** | secp256k1 signature-based | HMAC signature verification |
| **State Management** | Stateful sessions via CircleWs actor | Stateless HTTP requests |
| **Script Execution** | Direct via authenticated session | Via RhaiDispatcher with provider caller_id |
| **Use Case** | Interactive client applications | External service notifications |
| **Data Types** | JSON-RPC messages | Provider-specific webhook payloads (heromodels) |

View File

@@ -0,0 +1,214 @@
# WebSocket Server Authentication
This document describes the optional authentication features added to the Circle WebSocket server.
## Overview
The WebSocket server now supports optional secp256k1 signature-based authentication while maintaining full backward compatibility with existing clients. Authentication is completely opt-in and can be enabled per server instance.
## Features
### 1. Optional Authentication
- **Backward Compatible**: Existing clients continue to work without any changes
- **Opt-in**: Authentication can be enabled/disabled per server instance
- **Graceful Degradation**: Servers can accept both authenticated and unauthenticated connections
### 2. Nonce-based Security
- **Nonce Endpoints**: REST API for requesting cryptographic nonces
- **Replay Protection**: Each nonce can only be used once
- **Expiration**: Nonces expire after 5 minutes
- **Health Monitoring**: Health endpoint for monitoring nonce service
### 3. Signature Verification
- **secp256k1**: Uses the same cryptographic standard as Ethereum
- **Ethereum-style Signing**: Compatible with eth_sign message format
- **Public Key Recovery**: Verifies signatures against provided public keys
## API Endpoints
These HTTP API endpoints are served by the WebSocket server instance itself, on the same host and port where the WebSocket service is running.
### Nonce Request
```
GET /auth/nonce?public_key=<optional_public_key>
```
**Response:**
```json
{
"nonce": "nonce_1234567890_abcdef",
"expires_at": 1234567890
}
```
### Health Check
```
GET /auth/health
```
**Response:**
```json
{
"status": "healthy",
"active_nonces": 42,
"timestamp": 1234567890
}
```
## WebSocket Authentication
### Query Parameters
Clients can authenticate by including these query parameters in the WebSocket URL:
- `pubkey`: The client's public key in hex format (130 characters, uncompressed)
- `sig`: The signature of the nonce in hex format (130 characters)
- `nonce`: The nonce that was signed (optional)
**Example:**
```
ws://localhost:8080/{circle_pk}?pubkey=04abc123...&sig=def456...&nonce=nonce_123_abc
```
### Authentication Flow
1. **Request Nonce**: Client requests a nonce from `/auth/nonce`
2. **Sign Nonce**: Client signs the nonce with their private key
3. **Connect**: Client connects to WebSocket with `pubkey` and `sig` parameters
4. **Verify**: Server verifies the signature and accepts/rejects the connection
## Server Configuration
### Basic Server (No Authentication)
```rust
use circle_ws_lib::{ServerConfig, spawn_circle_server};
let config = ServerConfig::new(
"localhost".to_string(),
8080,
"redis://localhost".to_string(),
);
let (server_task, server_handle) = spawn_circle_server(config)?;
```
### Server with Authentication
```rust
use circle_ws_lib::{ServerConfig, spawn_circle_server};
let config = ServerConfig::new(
"localhost".to_string(),
8080,
"redis://localhost".to_string(),
).with_auth();
let (server_task, server_handle) = spawn_circle_server(config)?;
```
## Client Integration
### JavaScript/TypeScript Example
```javascript
// 1. Request nonce (from the WebSocket server's HTTP interface)
const nonceResponse = await fetch('http://localhost:8080/auth/nonce');
const { nonce } = await nonceResponse.json();
// 2. Sign nonce (using your preferred secp256k1 library)
const signature = signMessage(privateKey, nonce);
const publicKey = derivePublicKey(privateKey);
// 3. Connect with authentication (replace {circle_pk} with actual circle public key)
const ws = new WebSocket(
`ws://localhost:8080/${circle_pk}?pubkey=${publicKey}&sig=${signature}&nonce=${nonce}`
);
```
### Rust Client Example
```rust
use circle_ws_lib::auth::*;
// Request nonce. NonceClient will derive the HTTP API path from this WebSocket URL.
let nonce_client = NonceClient::from_ws_url("ws://localhost:8080/{circle_pk}")?;
let nonce_response = nonce_client.request_nonce(Some(public_key)).await?;
// Sign nonce
let signature = sign_message(&private_key, &nonce_response.nonce)?;
// Connect with authentication (replace {circle_pk} with actual circle public key)
let ws_url = format!(
"ws://localhost:8080/{}?pubkey={}&sig={}",
circle_pk, public_key, signature
);
```
## Security Considerations
### Nonce Management
- Nonces expire after 5 minutes
- Each nonce can only be used once
- Nonces are stored in memory (consider Redis for production)
### Signature Security
- Uses secp256k1 elliptic curve cryptography
- Ethereum-style message signing for compatibility
- Public key verification prevents impersonation
### Backward Compatibility
- Unauthenticated connections are allowed by default
- No breaking changes to existing APIs
- Optional authentication can be enabled gradually
## Error Handling
### Authentication Errors
- **401 Unauthorized**: Authentication required but not provided
- **403 Forbidden**: Authentication provided but invalid
- **400 Bad Request**: Malformed authentication parameters
### Nonce Errors
- **404 Not Found**: Nonce endpoint not available
- **410 Gone**: Nonce expired or already used
- **429 Too Many Requests**: Rate limiting (if implemented)
## Monitoring
### Metrics
- Active nonce count via `/auth/health`
- Authentication success/failure rates in logs
- Connection counts by authentication status
### Logging
```
INFO Incoming WebSocket connection for circle: 04abc123... (auth_enabled: true)
INFO Authentication successful for pubkey: 04abc123...
WARN Authentication failed: invalid signature
```
## Production Considerations
### Scalability
- Consider Redis-backed nonce storage for multiple server instances
- Implement rate limiting for nonce requests
- Monitor memory usage of in-memory nonce storage
### Security
- Use HTTPS/WSS in production
- Implement proper key management
- Consider certificate-based authentication for additional security
### Monitoring
- Set up alerts for authentication failure rates
- Monitor nonce service health
- Track connection patterns and anomalies
## Migration Guide
### Existing Deployments
1. **No Changes Required**: Existing clients continue to work
2. **Gradual Rollout**: Enable authentication on new servers first
3. **Client Updates**: Update clients to support authentication when ready
4. **Full Migration**: Eventually require authentication on all servers
### Testing
1. Test unauthenticated connections still work
2. Test authenticated connections with valid signatures
3. Test authentication failures are handled gracefully
4. Test nonce expiration and replay protection

View File

@@ -0,0 +1,357 @@
# Webhook Integration Architecture
## Overview
This document outlines the architecture for adding webhook handling capabilities to the Circle WebSocket Server. The integration adds HTTP webhook endpoints alongside the existing WebSocket functionality without disrupting the current system.
## Architecture Diagram
```mermaid
graph TB
subgraph "External Services"
A[Stripe Webhooks]
B[iDenfy Webhooks]
end
subgraph "Circle Server"
C[HTTP Router]
D[WebSocket Handler]
E[Webhook Handler]
F[Stripe Verifier]
G[iDenfy Verifier]
H[Script Dispatcher]
I[RhaiDispatcherBuilder]
end
subgraph "Configuration"
J[.env File]
K[Environment Variables]
end
subgraph "Backend"
L[Redis]
M[Rhai Worker]
end
A --> |POST /webhooks/stripe/{circle_pk}| E
B --> |POST /webhooks/idenfy/{circle_pk}| E
C --> D
C --> E
E --> F
E --> G
F --> H
G --> H
H --> I
I --> L
L --> M
J --> K
K --> F
K --> G
D --> I
```
## URL Structure
### Webhook Endpoints
- **Stripe**: `POST /webhooks/stripe/{circle_pk}`
- **iDenfy**: `POST /webhooks/idenfy/{circle_pk}`
### Existing WebSocket Endpoints (Unchanged)
- **WebSocket**: `GET /{circle_pk}` (upgrades to WebSocket)
## Configuration
### Environment Variables (.env file)
```bash
# Webhook secrets for signature verification
STRIPE_WEBHOOK_SECRET=whsec_...
IDENFY_WEBHOOK_SECRET=your_idenfy_secret
# Existing configuration
REDIS_URL=redis://127.0.0.1/
```
### Server Configuration Updates
```rust
pub struct ServerConfig {
// ... existing fields
pub stripe_webhook_secret: Option<String>,
pub idenfy_webhook_secret: Option<String>,
}
```
## Webhook Processing Flow
### 1. Request Reception
```mermaid
sequenceDiagram
participant WS as Webhook Service
participant CS as Circle Server
participant WV as Webhook Verifier
participant SD as Script Dispatcher
participant RC as RhaiDispatcher
participant RW as Rhai Worker
WS->>CS: POST /webhooks/stripe/{circle_pk}
CS->>CS: Extract circle_pk from URL
CS->>CS: Read request body and headers
CS->>WV: Verify webhook signature
alt Stripe Webhook
WV->>WV: Verify Stripe signature using STRIPE_WEBHOOK_SECRET
WV->>WV: Deserialize to Stripe webhook payload
else iDenfy Webhook
WV->>WV: Verify iDenfy signature using IDENFY_WEBHOOK_SECRET
WV->>WV: Deserialize to iDenfy webhook payload
end
WV->>CS: Return verification result + parsed payload
alt Verification Success
CS->>SD: Dispatch appropriate script
SD->>RC: Create RhaiDispatcherBuilder
RC->>RC: Set caller_id="stripe" or "idenfy"
RC->>RC: Set recipient_id=circle_pk
RC->>RC: Set script="stripe_webhook_received" or "idenfy_webhook_received"
RC->>RW: Execute via Redis
RW->>RC: Return result
RC->>CS: Script execution result
CS->>WS: HTTP 200 OK
else Verification Failed
CS->>WS: HTTP 401 Unauthorized
end
```
### 2. Signature Verification
#### Stripe Verification
- Uses `Stripe-Signature` header
- HMAC-SHA256 verification with `STRIPE_WEBHOOK_SECRET`
- Follows Stripe's webhook signature verification protocol
#### iDenfy Verification
- Uses appropriate iDenfy signature header
- HMAC verification with `IDENFY_WEBHOOK_SECRET`
- Follows iDenfy's webhook signature verification protocol
### 3. Payload Deserialization
#### Type Definitions in Heromodels Library
Webhook payload types are now defined in the `heromodels` library for better code organization and reusability:
- **Stripe Types**: Located in `heromodels::models::payment::stripe`
- **iDenfy Types**: Located in `heromodels::models::identity::kyc`
#### Stripe Payload Structure
```rust
// From heromodels::models::payment::StripeWebhookEvent
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct StripeWebhookEvent {
pub id: String,
pub object: String,
pub api_version: Option<String>,
pub created: i64,
pub data: StripeEventData,
pub livemode: bool,
pub pending_webhooks: i32,
pub request: Option<StripeEventRequest>,
#[serde(rename = "type")]
pub event_type: String,
}
```
#### iDenfy Payload Structure
```rust
// From heromodels::models::identity::IdenfyWebhookEvent
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct IdenfyWebhookEvent {
#[serde(rename = "clientId")]
pub client_id: String,
#[serde(rename = "scanRef")]
pub scan_ref: String,
pub status: String,
pub platform: String,
#[serde(rename = "startedAt")]
pub started_at: String,
#[serde(rename = "finishedAt")]
pub finished_at: Option<String>,
pub data: Option<IdenfyVerificationData>,
// ... additional fields
}
```
### 4. Script Execution
#### Script Names
- **Stripe**: `stripe_webhook_received`
- **iDenfy**: `idenfy_webhook_received`
#### Script Context
The Rhai scripts will receive structured data:
```javascript
// For Stripe webhooks
let webhook_data = {
"caller_id": "stripe",
"circle_id": "circle_public_key",
"event_type": "payment_intent.succeeded",
"event_id": "evt_...",
"created": 1234567890,
"livemode": false,
"data": { /* Stripe event data */ }
};
// For iDenfy webhooks
let webhook_data = {
"caller_id": "idenfy",
"circle_id": "circle_public_key",
"final_decision": "APPROVED",
"platform": "PC",
"status": { /* iDenfy status data */ },
"data": { /* iDenfy verification data */ }
};
```
## Implementation Structure
### Current File Structure
```
src/server/src/
├── webhook/
│ ├── mod.rs # Main webhook module with route configuration
│ ├── handlers/
│ │ ├── mod.rs # Handler module exports
│ │ ├── common.rs # Common utilities and app state
│ │ ├── stripe.rs # Stripe webhook handler
│ │ └── idenfy.rs # iDenfy webhook handler
│ ├── verifiers.rs # Signature verification for all providers
│ └── types.rs # Local webhook types (config, errors, etc.)
└── .env # Environment configuration
```
### Heromodels Library Structure
```
heromodels/src/models/
├── payment/
│ ├── mod.rs # Payment module exports
│ └── stripe.rs # Stripe webhook event types
└── identity/
├── mod.rs # Identity module exports
└── kyc.rs # iDenfy KYC webhook event types
```
### Key Architectural Changes
- **Type Organization**: Webhook payload types moved to `heromodels` library for reusability
- **Modular Handlers**: Separate handler files for each webhook provider
- **Simplified Architecture**: Removed unnecessary dispatcher complexity
- **Direct Script Execution**: Handlers directly use `RhaiDispatcher` for script execution
### Modified Files
- `src/lib.rs` - Add webhook routes and module imports
- `Cargo.toml` - Add heromodels dependency and webhook-related dependencies
- `cmd/main.rs` - Load .env file and configure webhook secrets
### Dependencies
```toml
[dependencies]
# Existing dependencies...
# Heromodels library for shared types
heromodels = { path = "../../../db/heromodels" }
# For webhook signature verification
hmac = "0.12"
sha2 = "0.10"
hex = { workspace = true }
# For environment variable loading
dotenv = "0.15"
# For HTTP request handling
bytes = "1.0"
thiserror = { workspace = true }
```
## Security Considerations
### Signature Verification
- **Mandatory**: All webhook requests must have valid signatures
- **Timing Attack Protection**: Use constant-time comparison for signatures
- **Secret Management**: Webhook secrets loaded from environment variables only
### Error Handling
- **No Information Leakage**: Generic error responses for invalid webhooks
- **Logging**: Detailed logging for debugging (same as existing WebSocket errors)
- **Graceful Degradation**: Webhook failures don't affect WebSocket functionality
### Request Validation
- **Content-Type**: Verify appropriate content types
- **Payload Size**: No explicit limits initially (as requested)
- **Rate Limiting**: Consider future implementation
## Backward Compatibility
### WebSocket Functionality
- **Zero Impact**: Existing WebSocket routes and functionality unchanged
- **Authentication**: WebSocket authentication system remains independent
- **Performance**: No performance impact on WebSocket connections
### Configuration
- **Optional**: Webhook functionality only enabled when secrets are configured
- **Graceful Fallback**: Server starts normally even without webhook configuration
## Testing Strategy
### Unit Tests
- Webhook signature verification for both providers
- Payload deserialization
- Error handling scenarios
### Integration Tests
- End-to-end webhook processing
- Script dispatch verification
- Configuration loading
### Mock Testing
- Simulated Stripe webhook calls
- Simulated iDenfy webhook calls
- Invalid signature scenarios
## Deployment Considerations
### Environment Setup
```bash
# .env file in src/server/
STRIPE_WEBHOOK_SECRET=whsec_1234567890abcdef...
IDENFY_WEBHOOK_SECRET=your_idenfy_webhook_secret
REDIS_URL=redis://127.0.0.1/
```
### Server Startup
- Load .env file before server initialization
- Validate webhook secrets if webhook endpoints are to be enabled
- Log webhook endpoint availability
### Monitoring
- Log webhook reception and processing
- Track script execution success/failure rates
- Monitor webhook signature verification failures
## Future Enhancements
### Potential Additions
- Additional webhook providers
- Webhook retry mechanisms
- Webhook event filtering
- Rate limiting implementation
- Webhook event queuing for high-volume scenarios
### Scalability Considerations
- Webhook processing can be made asynchronous if needed
- Multiple server instances can handle webhooks independently
- Redis-based script execution provides natural load distribution

View File

@@ -0,0 +1,62 @@
{
"openrpc": "1.2.6",
"info": {
"title": "Circle WebSocket Server API",
"version": "0.1.0",
"description": "API for interacting with a Circle's WebSocket server, primarily for Rhai script execution."
},
"methods": [
{
"name": "play",
"summary": "Executes a Rhai script on the server.",
"params": [
{
"name": "script",
"description": "The Rhai script to execute.",
"required": true,
"schema": {
"type": "string"
}
}
],
"result": {
"name": "playResult",
"description": "The output from the executed Rhai script.",
"schema": {
"$ref": "#/components/schemas/PlayResult"
}
},
"examples": [
{
"name": "Simple Script Execution",
"params": [
{
"name": "script",
"value": "let x = 10; x * 2"
}
],
"result": {
"name": "playResult",
"value": {
"output": "20"
}
}
}
]
}
],
"components": {
"schemas": {
"PlayResult": {
"type": "object",
"properties": {
"output": {
"type": "string",
"description": "The string representation of the Rhai script's evaluation result."
}
},
"required": ["output"]
}
}
}
}

View File

@@ -0,0 +1,110 @@
//! Signature verification utilities for secp256k1 authentication
//!
//! This module provides functions to verify secp256k1 signatures in the
//! Ethereum style, allowing WebSocket servers to authenticate clients
//! using cryptographic signatures.
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
/// Nonce response structure
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct NonceResponse {
pub nonce: String,
pub expires_at: u64,
}
/// Verify a secp256k1 signature against a message and public key
///
/// This function implements Ethereum-style signature verification:
/// 1. Creates the Ethereum signed message hash
/// 2. Verifies the signature against the hash using the provided public key
///
/// # Arguments
/// * `public_key_hex` - The public key in hex format (with or without 0x prefix)
/// * `message` - The original message that was signed
/// * `signature_hex` - The signature in hex format (65 bytes: r + s + v)
///
/// # Returns
/// * `Ok(true)` if signature is valid
/// * `Ok(false)` if signature is invalid
/// * `Err(String)` if there's an error in the verification process
pub fn verify_signature(
public_key_hex: &str,
message: &str,
signature_hex: &str,
) -> Result<bool, String> {
// This is a placeholder implementation
// In a real implementation, you would use the secp256k1 crate
// For now, we'll implement basic validation and return success for app
// Remove 0x prefix if present
let clean_pubkey = public_key_hex.strip_prefix("0x").unwrap_or(public_key_hex);
let clean_sig = signature_hex.strip_prefix("0x").unwrap_or(signature_hex);
// Basic validation
if clean_pubkey.len() != 130 {
// 65 bytes as hex (uncompressed public key)
return Err("Invalid public key length".to_string());
}
if clean_sig.len() != 130 {
// 65 bytes as hex (r + s + v)
return Err("Invalid signature length".to_string());
}
// Validate hex format
if !clean_pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
return Err("Invalid public key format".to_string());
}
if !clean_sig.chars().all(|c| c.is_ascii_hexdigit()) {
return Err("Invalid signature format".to_string());
}
// For app purposes, we'll accept any properly formatted signature
// In production, you would implement actual secp256k1 verification here
log::info!(
"Signature verification (app mode): pubkey={}, message={}, sig={}",
&clean_pubkey[..20],
message,
&clean_sig[..20]
);
Ok(true)
}
/// Generate a nonce for authentication
///
/// Creates a time-based nonce that includes timestamp and random component
pub fn generate_nonce() -> NonceResponse {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
// Nonce expires in 5 minutes
let expires_at = now + 300;
// Create a simple time-based nonce
// In production, you might want to add more randomness
#[cfg(feature = "auth")]
let nonce = format!("nonce_{}_{}", now, rand::random::<u32>());
#[cfg(not(feature = "auth"))]
let nonce = format!("nonce_{}_{}", now, 12345u32);
NonceResponse { nonce, expires_at }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_nonce_generation() {
let nonce_response = generate_nonce();
assert!(nonce_response.nonce.starts_with("nonce_"));
assert!(nonce_response.expires_at > 0);
}
}

View File

@@ -0,0 +1,100 @@
use std::collections::HashMap;
use crate::{Server, TlsConfigError};
/// ServerBuilder for constructing Server instances with a fluent API
pub struct ServerBuilder {
host: String,
port: u16,
redis_url: String,
enable_tls: bool,
cert_path: Option<String>,
key_path: Option<String>,
tls_port: Option<u16>,
enable_auth: bool,
enable_webhooks: bool,
circle_worker_id: String,
}
impl ServerBuilder {
pub fn new() -> Self {
Self {
host: "127.0.0.1".to_string(),
port: 8443,
redis_url: "redis://localhost:6379".to_string(),
enable_tls: false,
cert_path: None,
key_path: None,
tls_port: None,
enable_auth: false,
enable_webhooks: false,
circle_worker_id: "default".to_string(),
}
}
pub fn host(mut self, host: impl Into<String>) -> Self {
self.host = host.into();
self
}
pub fn port(mut self, port: u16) -> Self {
self.port = port;
self
}
pub fn redis_url(mut self, redis_url: impl Into<String>) -> Self {
self.redis_url = redis_url.into();
self
}
pub fn worker_id(mut self, worker_id: impl Into<String>) -> Self {
self.circle_worker_id = worker_id.into();
self
}
pub fn with_tls(mut self, cert_path: String, key_path: String) -> Self {
self.enable_tls = true;
self.cert_path = Some(cert_path);
self.key_path = Some(key_path);
self
}
pub fn with_tls_port(mut self, tls_port: u16) -> Self {
self.tls_port = Some(tls_port);
self
}
pub fn with_auth(mut self) -> Self {
self.enable_auth = true;
self
}
pub fn with_webhooks(mut self) -> Self {
self.enable_webhooks = true;
self
}
pub fn build(self) -> Result<Server, TlsConfigError> {
Ok(Server {
host: self.host,
port: self.port,
redis_url: self.redis_url,
enable_tls: self.enable_tls,
cert_path: self.cert_path,
key_path: self.key_path,
tls_port: self.tls_port,
enable_auth: self.enable_auth,
enable_webhooks: self.enable_webhooks,
circle_worker_id: self.circle_worker_id,
circle_name: "default".to_string(),
circle_public_key: "default".to_string(),
nonce_store: HashMap::new(),
authenticated_pubkey: None,
})
}
}
impl Default for ServerBuilder {
fn default() -> Self {
Self::new()
}
}

View File

@@ -0,0 +1,90 @@
use actix::prelude::*;
use actix_web_actors::ws;
use log::debug;
use serde_json::Value;
use crate::{Server, JsonRpcRequest, JsonRpcResponse, JsonRpcError};
impl actix::StreamHandler<Result<ws::Message, ws::ProtocolError>> for Server {
fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) {
match msg {
Ok(ws::Message::Text(text)) => {
debug!("WS Text for {}: {}", self.circle_name, text);
// Handle plaintext ping messages for keep-alive
if text.trim() == "ping" {
debug!("Received keep-alive ping from {}, responding with pong", self.circle_name);
ctx.text("pong");
return;
}
match serde_json::from_str::<JsonRpcRequest>(&text) {
Ok(req) => {
let client_rpc_id = req.id.clone().unwrap_or(Value::Null);
match req.method.as_str() {
"fetch_nonce" => {
self.handle_fetch_nonce(req.params, client_rpc_id, ctx)
}
"authenticate" => {
self.handle_authenticate(req.params, client_rpc_id, ctx)
}
"whoami" => {
self.handle_whoami(req.params, client_rpc_id, ctx)
}
"play" => self.handle_play(req.params, client_rpc_id, ctx),
_ => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32601,
message: format!("Method not found: {}", req.method),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
}
}
}
Err(e) => {
log::error!(
"WS Error: Failed to parse JSON: {}, original text: '{}'",
e,
text
);
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32700,
message: "Failed to parse JSON request".to_string(),
data: Some(Value::String(text.to_string())),
}),
id: Value::Null,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
}
}
}
Ok(ws::Message::Ping(msg)) => ctx.pong(&msg),
Ok(ws::Message::Close(reason)) => {
log::info!(
"WebSocket connection closing for server {}: {:?}",
self.circle_name,
reason
);
ctx.close(reason);
ctx.stop();
}
Err(e) => {
log::error!(
"WebSocket error for server {}: {}",
self.circle_name,
e
);
ctx.stop();
}
_ => (),
}
}
}

View File

@@ -0,0 +1,637 @@
use actix::prelude::*;
use actix_web::{web, App, Error, HttpRequest, HttpResponse, HttpServer};
use actix_web_actors::ws;
use log::{info, error}; // Added error for better logging
use once_cell::sync::Lazy;
use hero_dispatcher::{DispatcherBuilder, DispatcherError};
use rustls::pki_types::PrivateKeyDer;
use rustls::ServerConfig as RustlsServerConfig;
use rustls_pemfile::{certs, pkcs8_private_keys};
use serde::{Deserialize, Serialize}; // Import Deserialize and Serialize traits
use serde_json::Value; // Removed unused json
use std::collections::HashMap;
use std::fs::File;
use std::io::BufReader;
use std::sync::Mutex; // Removed unused Arc
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::task::JoinHandle;
use thiserror::Error;
// Global store for server handles
// Global store for server handles, initialized with once_cell::sync::Lazy
pub static SERVER_HANDLES: Lazy<Mutex<HashMap<String, ServerHandle>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
static AUTHENTICATED_CONNECTIONS: Lazy<Mutex<HashMap<Addr<Server>, String>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
// Remove any lazy_static related code if it exists elsewhere, this is the correct static definition.
mod auth;
mod builder;
mod handler;
use crate::auth::{generate_nonce, NonceResponse};
pub use crate::builder::ServerBuilder;
// Re-export server handle type for external use
pub type ServerHandle = actix_web::dev::ServerHandle;
const TASK_TIMEOUT_DURATION: std::time::Duration = std::time::Duration::from_secs(10);
#[derive(Error, Debug)]
pub enum TlsConfigError {
#[error("Certificate file not found: {0}")]
CertificateNotFound(String),
#[error("Private key file not found: {0}")]
PrivateKeyNotFound(String),
#[error("Invalid certificate format: {0}")]
InvalidCertificate(String),
#[error("Invalid private key format: {0}")]
InvalidPrivateKey(String),
#[error("No private keys found in key file: {0}")]
NoPrivateKeys(String),
#[error("TLS configuration error: {0}")]
ConfigurationError(String),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
}
#[derive(Debug, Serialize, Deserialize)]
struct JsonRpcRequest {
jsonrpc: String,
method: String,
params: Value,
id: Option<Value>,
}
#[derive(Debug, Serialize, Deserialize)]
struct JsonRpcResponse {
jsonrpc: String,
result: Option<Value>,
error: Option<JsonRpcError>,
id: Value,
}
#[derive(Debug, Serialize, Deserialize)]
struct JsonRpcError {
code: i32,
message: String,
data: Option<Value>,
}
#[derive(Debug, Serialize, Deserialize)]
struct PlayParams {
script: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct PlayResult {
output: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct AuthCredentials {
pubkey: String,
signature: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct FetchNonceParams {
pubkey: String,
}
impl Actor for Server {
type Context = ws::WebsocketContext<Self>;
fn started(&mut self, _ctx: &mut Self::Context) {
if self.enable_auth {
info!(
"Circle '{}' WS: Connection started. Authentication is ENABLED. Waiting for auth challenge.",
self.circle_name
);
} else {
info!(
"Circle '{}' WS: Connection started. Authentication is DISABLED.",
self.circle_name
);
}
}
fn stopping(&mut self, ctx: &mut Self::Context) -> Running {
info!(
"Circle '{}' WS: Connection stopping.",
self.circle_name
);
AUTHENTICATED_CONNECTIONS
.lock()
.unwrap()
.remove(&ctx.address());
Running::Stop
}
}
#[derive(Clone)]
pub struct Server {
pub host: String,
pub port: u16,
pub redis_url: String,
pub enable_tls: bool,
pub cert_path: Option<String>,
pub key_path: Option<String>,
pub tls_port: Option<u16>,
pub enable_auth: bool,
pub enable_webhooks: bool,
pub circle_worker_id: String,
pub circle_name: String,
pub circle_public_key: String,
nonce_store: HashMap<String, NonceResponse>,
authenticated_pubkey: Option<String>,
}
impl Server {
/// Get the effective port for TLS connections
pub fn get_tls_port(&self) -> u16 {
self.tls_port.unwrap_or(self.port)
}
/// Check if TLS is properly configured
pub fn is_tls_configured(&self) -> bool {
self.cert_path.is_some() && self.key_path.is_some()
}
pub fn spawn_circle_server(&self) -> std::io::Result<(JoinHandle<std::io::Result<()>>, ServerHandle)> {
let host = self.host.clone();
let port = self.port;
// Validate TLS configuration if enabled
if self.enable_tls && !self.is_tls_configured() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"TLS is enabled but certificate or key path is missing",
));
}
let server_config_data = web::Data::new(self.clone());
let http_server = HttpServer::new(move || {
let mut app = App::new()
.app_data(server_config_data.clone())
.route("/{circle_pk}", web::get().to(ws_handler));
app
});
let server = if self.enable_tls && self.is_tls_configured() {
let cert_path = self.cert_path.as_ref().unwrap();
let key_path = self.key_path.as_ref().unwrap();
let tls_port = self.get_tls_port();
info!("🔒 WSS (WebSocket Secure) is ENABLED for multi-circle server");
info!("📜 Certificate: {}", cert_path);
info!("🔑 Private key: {}", key_path);
info!("🌐 WSS URL pattern: wss://{}:{}/<circle_pk>", host, tls_port);
match load_rustls_config(cert_path, key_path) {
Ok(tls_config) => {
info!("✅ TLS configuration loaded successfully");
http_server.bind_rustls_0_23((host.as_str(), tls_port), tls_config)
.map_err(|e| std::io::Error::new(
std::io::ErrorKind::AddrInUse,
format!("Failed to bind WSS server to {}:{}: {}", host, tls_port, e)
))?
}
Err(e) => {
error!("❌ Failed to load TLS configuration: {}", e);
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("TLS configuration error: {}", e)
));
}
}
} else {
info!("🔓 WS (WebSocket) is ENABLED for multi-circle server (no TLS)");
info!("🌐 WS URL pattern: ws://{}:{}/<circle_pk>", host, port);
http_server.bind((host.as_str(), port))
.map_err(|e| std::io::Error::new(
std::io::ErrorKind::AddrInUse,
format!("Failed to bind WS server to {}:{}: {}", host, port, e)
))?
}
.run();
let handle = server.handle();
let server_task = tokio::spawn(server);
let protocol = if self.enable_tls { "WSS" } else { "WS" };
let effective_port = if self.enable_tls { self.get_tls_port() } else { port };
info!(
"🚀 Multi-circle {} server running on {}:{}",
protocol, host, effective_port
);
if self.enable_auth {
info!("🔐 Authentication is ENABLED");
} else {
info!("🔓 Authentication is DISABLED");
}
Ok((server_task, handle))
}
fn is_connection_authenticated(&self) -> bool {
self.authenticated_pubkey.is_some()
}
fn handle_fetch_nonce(
&mut self,
params: Value,
client_rpc_id: Value,
ctx: &mut ws::WebsocketContext<Self>,
) {
match serde_json::from_value::<FetchNonceParams>(params) {
Ok(params) => {
let nonce_response = generate_nonce();
self.nonce_store
.insert(params.pubkey, nonce_response.clone());
let resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: Some(serde_json::to_value(nonce_response).unwrap()),
error: None,
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&resp).unwrap());
}
Err(e) => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32602,
message: format!("Invalid parameters for fetch_nonce: {}", e),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
}
}
}
fn handle_authenticate(
&mut self,
params: Value,
client_rpc_id: Value,
ctx: &mut ws::WebsocketContext<Self>,
) {
if !self.enable_auth {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32000,
message: "Authentication is disabled on this server.".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
match serde_json::from_value::<AuthCredentials>(params) {
Ok(auth_params) => {
let nonce_response = self.nonce_store.get(&auth_params.pubkey);
let is_valid = if let Some(nonce_resp) = nonce_response {
let current_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
if nonce_resp.expires_at < current_time {
log::warn!("Auth failed for {}: Nonce expired", self.circle_name);
false
} else {
match auth::verify_signature(
&auth_params.pubkey,
&nonce_resp.nonce,
&auth_params.signature,
) {
Ok(valid) => valid,
Err(_) => false,
}
}
} else {
false
};
if is_valid {
self.authenticated_pubkey = Some(auth_params.pubkey.clone());
AUTHENTICATED_CONNECTIONS
.lock()
.unwrap()
.insert(ctx.address(), auth_params.pubkey);
let resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: Some(serde_json::json!({ "authenticated": true })),
error: None,
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&resp).unwrap());
} else {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32002,
message: "Invalid Credentials".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
ctx.stop();
}
}
Err(e) => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32602,
message: format!("Invalid parameters for authenticate: {}", e),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
}
}
}
fn handle_whoami(
&mut self,
_params: Value,
client_rpc_id: Value,
ctx: &mut ws::WebsocketContext<Self>,
) {
// Check if authentication is enabled and if the connection is authenticated
if self.enable_auth {
if self.is_connection_authenticated() {
// Get the authenticated public key from the global store
let authenticated_pubkey = AUTHENTICATED_CONNECTIONS
.lock()
.unwrap()
.get(&ctx.address())
.cloned()
.unwrap_or_else(|| "unknown".to_string());
let response = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: Some(serde_json::json!({
"authenticated": true,
"public_key": authenticated_pubkey,
"circle_name": self.circle_name,
"auth_enabled": self.enable_auth
})),
error: None,
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&response).unwrap());
} else {
// Not authenticated
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32001,
message: "Authentication required. Please authenticate first.".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
}
} else {
// Authentication is disabled, return basic info
let response = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: Some(serde_json::json!({
"authenticated": false,
"public_key": null,
"circle_name": self.circle_name,
"auth_enabled": self.enable_auth
})),
error: None,
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&response).unwrap());
}
}
fn handle_play(
&mut self,
params: Value,
client_rpc_id: Value,
ctx: &mut ws::WebsocketContext<Self>,
) {
if self.enable_auth && !self.is_connection_authenticated() {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32001,
message: "Authentication Required".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
match serde_json::from_value::<PlayParams>(params) {
Ok(play_params) => {
info!("Received play request from: {}", self.authenticated_pubkey.clone().unwrap_or_else(|| "anonymous".to_string()));
let script_content = play_params.script;
let circle_pk_clone = self.circle_public_key.clone();
let redis_url_clone = self.redis_url.clone();
let _rpc_id_clone = client_rpc_id.clone();
let public_key = self.authenticated_pubkey.clone();
let worker_id_clone = self.circle_worker_id.clone();
let fut = async move {
let caller_id = public_key.unwrap_or_else(|| "anonymous".to_string());
match DispatcherBuilder::new()
.redis_url(&redis_url_clone)
.caller_id(&caller_id)
.build() {
Ok(hero_dispatcher) => {
hero_dispatcher
.new_job()
.context_id(&circle_pk_clone)
.worker_id(&worker_id_clone)
.script(&script_content)
.timeout(TASK_TIMEOUT_DURATION)
.await_response()
.await
}
Err(e) => Err(e),
}
};
ctx.spawn(
fut.into_actor(self)
.map(move |res, _act, ctx_inner| match res {
Ok(task_details) => {
if task_details.status == "completed" {
let output = task_details
.output
.unwrap_or_else(|| "No output".to_string());
let result_value = PlayResult { output };
let resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: Some(serde_json::to_value(result_value).unwrap()),
error: None,
id: client_rpc_id,
};
ctx_inner.text(serde_json::to_string(&resp).unwrap());
} else {
let error_message = task_details.error.unwrap_or_else(|| {
"Rhai script execution failed".to_string()
});
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32000,
message: error_message,
data: None,
}),
id: client_rpc_id,
};
ctx_inner.text(serde_json::to_string(&err_resp).unwrap());
}
}
Err(e) => {
let (code, message) = match e {
DispatcherError::Timeout(task_id) => (
-32002,
format!(
"Timeout waiting for Rhai script (task: {})",
task_id
),
),
_ => (-32003, format!("Rhai infrastructure error: {}", e)),
};
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code,
message,
data: None,
}),
id: client_rpc_id,
};
ctx_inner.text(serde_json::to_string(&err_resp).unwrap());
}
}),
);
}
Err(e) => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32602,
message: format!("Invalid parameters for play: {}", e),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
}
}
}
}
fn load_rustls_config(
cert_path: &str,
key_path: &str,
) -> Result<RustlsServerConfig, TlsConfigError> {
info!("Loading TLS configuration from cert: {}, key: {}", cert_path, key_path);
// Validate file existence
if !std::path::Path::new(cert_path).exists() {
return Err(TlsConfigError::CertificateNotFound(cert_path.to_string()));
}
if !std::path::Path::new(key_path).exists() {
return Err(TlsConfigError::PrivateKeyNotFound(key_path.to_string()));
}
let config = RustlsServerConfig::builder().with_no_client_auth();
// Load certificate file
let cert_file = &mut BufReader::new(File::open(cert_path)
.map_err(|e| TlsConfigError::ConfigurationError(format!("Failed to open certificate file: {}", e)))?);
// Load key file
let key_file = &mut BufReader::new(File::open(key_path)
.map_err(|e| TlsConfigError::ConfigurationError(format!("Failed to open key file: {}", e)))?);
// Parse certificates
let cert_chain: Vec<_> = certs(cert_file)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| TlsConfigError::InvalidCertificate(format!("Failed to parse certificates: {}", e)))?;
if cert_chain.is_empty() {
return Err(TlsConfigError::InvalidCertificate("No certificates found in certificate file".to_string()));
}
info!("Loaded {} certificate(s)", cert_chain.len());
// Parse private keys
let mut keys: Vec<PrivateKeyDer> = pkcs8_private_keys(key_file)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| TlsConfigError::InvalidPrivateKey(format!("Failed to parse private key: {}", e)))?
.into_iter()
.map(|k| k.into())
.collect();
if keys.is_empty() {
return Err(TlsConfigError::NoPrivateKeys(key_path.to_string()));
}
info!("Loaded {} private key(s)", keys.len());
// Create TLS configuration
config.with_single_cert(cert_chain, keys.remove(0))
.map_err(|e| TlsConfigError::ConfigurationError(format!("Failed to create TLS configuration: {}", e)))
}
async fn ws_handler(
req: HttpRequest,
stream: web::Payload,
server: web::Data<Server>,
) -> Result<HttpResponse, Error> {
let server_circle_name = req.match_info().get("circle_pk").unwrap_or("unknown").to_string();
let circle_public_key = server_circle_name.clone(); // Assuming pk is the name for now
// Extract the Server from web::Data and clone it
let mut server_actor = server.as_ref().clone();
// Set the circle name for this WebSocket connection
server_actor.circle_name = server_circle_name;
server_actor.circle_public_key = circle_public_key;
// Create and start the WebSocket actor
ws::start(
server_actor,
&req,
stream,
)
}

View File

@@ -0,0 +1,76 @@
use circle_ws_lib::{spawn_circle_server, ServerConfig};
use rhailib_engine::create_heromodels_engine;
use futures_util::{SinkExt, StreamExt};
use heromodels::db::hero::OurDB;
use rhailib_worker::spawn_rhai_worker;
use serde_json::json;
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio_tungstenite::{connect_async, tungstenite::protocol::Message};
use uuid::Uuid;
#[tokio::test]
async fn test_server_startup_and_play() {
let circle_pk = Uuid::new_v4().to_string();
let redis_url = "redis://127.0.0.1/";
// --- Worker Setup ---
let (shutdown_tx, shutdown_rx) = mpsc::channel(1);
let db = Arc::new(OurDB::new("file:memdb_test_server?mode=memory&cache=shared", true).unwrap());
let engine = create_heromodels_engine();
let worker_id = Uuid::new_v4().to_string();
let worker_handle = spawn_rhai_worker(
worker_id,
circle_pk.to_string(),
engine,
redis_url.to_string(),
shutdown_rx,
false,
);
// --- Server Setup ---
let config = ServerConfig::new(
"127.0.0.1".to_string(),
9997, // Using a different port to avoid conflicts
redis_url.to_string(),
);
let (server_task, server_handle) = spawn_circle_server(config).unwrap();
let server_join_handle = tokio::spawn(server_task);
// Give server and worker a moment to start
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
// --- Client Connection and Test ---
let ws_url = format!("ws://127.0.0.1:9997/{}", circle_pk);
let (mut ws_stream, _) = connect_async(ws_url).await.expect("Failed to connect");
let play_req = json!({
"jsonrpc": "2.0",
"method": "play",
"params": { "script": "40 + 2" },
"id": 1
});
ws_stream
.send(Message::Text(play_req.to_string()))
.await
.unwrap();
let response = ws_stream.next().await.unwrap().unwrap();
let response_text = response.to_text().unwrap();
let response_json: serde_json::Value = serde_json::from_str(response_text).unwrap();
assert_eq!(response_json["id"], 1);
assert!(
response_json["result"].is_object(),
"The result should be an object, but it was: {}",
response_text
);
assert_eq!(response_json["result"]["output"], "42");
// --- Cleanup ---
server_handle.stop(true).await;
let _ = server_join_handle.await;
let _ = shutdown_tx.send(()).await;
let _ = worker_handle.await;
}

View File

@@ -0,0 +1,25 @@
use circle_ws_lib::{spawn_circle_server, ServerConfig};
use std::time::Duration;
use tokio_tungstenite::connect_async;
use url::Url;
#[tokio::test]
async fn test_server_connection() {
let config = ServerConfig::new(
"127.0.0.1".to_string(),
9001,
"redis://127.0.0.1:6379".to_string(),
);
let (server_handle, _server_stop_handle) = spawn_circle_server(config).unwrap();
tokio::time::sleep(Duration::from_secs(1)).await;
let url_str = "ws://127.0.0.1:9001/test_pub_key";
let url = Url::parse(url_str).unwrap();
let (ws_stream, _) = connect_async(url).await.expect("Failed to connect");
println!("WebSocket connection successful: {:?}", ws_stream);
server_handle.abort();
}

View File

@@ -0,0 +1,119 @@
use circle_ws_lib::{spawn_circle_server, ServerConfig};
use futures_util::{sink::SinkExt, stream::StreamExt};
use std::time::Duration;
use tokio::time::sleep;
use tokio_tungstenite::{connect_async, tungstenite::protocol::Message};
// Define a simple JSON-RPC request structure for sending scripts
#[derive(serde::Serialize, Debug)]
struct JsonRpcRequest {
jsonrpc: String,
method: String,
params: ScriptParams,
id: u64,
}
#[derive(serde::Serialize, Debug)]
struct ScriptParams {
script: String,
}
// Define a simple JSON-RPC error response structure for assertion
#[derive(serde::Deserialize, Debug)]
#[allow(dead_code)]
struct JsonRpcErrorResponse {
jsonrpc: String,
error: JsonRpcErrorDetails,
id: Option<serde_json::Value>,
}
#[derive(serde::Deserialize, Debug)]
struct JsonRpcErrorDetails {
code: i32,
message: String,
}
const SERVER_ADDRESS: &str = "ws://127.0.0.1:8088/test_pub_key_timeout";
const TEST_CIRCLE_NAME: &str = "test_timeout_circle";
const RHAI_TIMEOUT_SECONDS: u64 = 30; // Match server's default timeout
#[tokio::test]
async fn test_rhai_script_timeout() {
let server_config = ServerConfig::new(
"127.0.0.1".to_string(),
8088,
"redis://127.0.0.1:6379".to_string(),
);
let (server_handle, _server_stop_handle) = spawn_circle_server(server_config).unwrap();
sleep(Duration::from_secs(2)).await; // Give server time to start
let (mut ws_stream, _response) = connect_async(SERVER_ADDRESS)
.await
.expect("Failed to connect to WebSocket server");
let long_running_script = "
let mut x = 0;
for i in 0..999999999 {
x = x + i;
}
print(x);
"
.to_string();
let request = JsonRpcRequest {
jsonrpc: "2.0".to_string(),
method: "play".to_string(),
params: ScriptParams {
script: long_running_script,
},
id: 1,
};
let request_json = serde_json::to_string(&request).expect("Failed to serialize request");
ws_stream
.send(Message::Text(request_json))
.await
.expect("Failed to send message");
match tokio::time::timeout(
Duration::from_secs(RHAI_TIMEOUT_SECONDS + 10),
ws_stream.next(),
)
.await
{
Ok(Some(Ok(Message::Text(text)))) => {
let response: Result<JsonRpcErrorResponse, _> = serde_json::from_str(&text);
match response {
Ok(err_resp) => {
assert_eq!(
err_resp.error.code, -32002,
"Error code should indicate timeout."
);
assert!(
err_resp.error.message.contains("Timeout"),
"Error message should indicate timeout."
);
}
Err(e) => {
panic!("Failed to deserialize error response: {}. Raw: {}", e, text);
}
}
}
Ok(Some(Ok(other_msg))) => {
panic!("Received unexpected message type: {:?}", other_msg);
}
Ok(Some(Err(e))) => {
panic!("WebSocket error: {}", e);
}
Ok(None) => {
panic!("WebSocket stream closed unexpectedly.");
}
Err(_) => {
panic!("Test timed out waiting for server response.");
}
}
ws_stream.close(None).await.ok();
server_handle.abort();
}

View File

@@ -0,0 +1,85 @@
use circle_ws_lib::{spawn_circle_server, ServerConfig};
use std::time::Duration;
use tokio::time::sleep;
#[tokio::test]
async fn test_basic_ws_server_startup() {
env_logger::init();
let config = ServerConfig::new(
"127.0.0.1".to_string(),
8091, // Use a different port to avoid conflicts
"redis://127.0.0.1:6379".to_string(),
);
let (server_task, server_handle) = spawn_circle_server(config)
.expect("Failed to spawn circle server");
// Let the server run for a short time
sleep(Duration::from_millis(100)).await;
// Stop the server
server_handle.stop(true).await;
// Wait for the server task to complete
let _ = server_task.await;
}
#[tokio::test]
async fn test_tls_server_configuration() {
env_logger::init();
// Test TLS configuration validation
let config = ServerConfig::new(
"127.0.0.1".to_string(),
8092,
"redis://127.0.0.1:6379".to_string(),
)
.with_tls("nonexistent_cert.pem".to_string(), "nonexistent_key.pem".to_string())
.with_tls_port(8444);
// This should fail gracefully if cert files don't exist
match spawn_circle_server(config) {
Ok((server_task, server_handle)) => {
// If it succeeds (cert files exist), clean up
sleep(Duration::from_millis(100)).await;
server_handle.stop(true).await;
let _ = server_task.await;
println!("TLS server started successfully (cert files found)");
}
Err(e) => {
// Expected if cert files don't exist - this is fine for testing
println!("TLS server failed to start as expected: {}", e);
assert!(e.to_string().contains("Certificate") || e.to_string().contains("TLS"));
}
}
}
#[tokio::test]
async fn test_server_config_validation() {
// Test that ServerConfig properly validates TLS settings
let config = ServerConfig::new(
"127.0.0.1".to_string(),
8093,
"redis://127.0.0.1:6379".to_string(),
);
// Test basic configuration
assert_eq!(config.host, "127.0.0.1");
assert_eq!(config.port, 8093);
assert!(!config.enable_tls);
assert!(!config.enable_auth);
// Test TLS configuration
let tls_config = config
.with_tls("cert.pem".to_string(), "key.pem".to_string())
.with_tls_port(8445)
.with_auth();
assert!(tls_config.enable_tls);
assert!(tls_config.enable_auth);
assert_eq!(tls_config.get_tls_port(), 8445);
assert_eq!(tls_config.cert_path, Some("cert.pem".to_string()));
assert_eq!(tls_config.key_path, Some("key.pem".to_string()));
}