initial commit
This commit is contained in:
62
interfaces/openrpc.json
Normal file
62
interfaces/openrpc.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
1
interfaces/unix/client/.gitignore
vendored
Normal file
1
interfaces/unix/client/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
6
interfaces/unix/client/Cargo.toml
Normal file
6
interfaces/unix/client/Cargo.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "hero-client-unix"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
3
interfaces/unix/client/src/main.rs
Normal file
3
interfaces/unix/client/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
}
|
0
interfaces/unix/examples/end_to_end.rs
Normal file
0
interfaces/unix/examples/end_to_end.rs
Normal file
1
interfaces/unix/server/.gitignore
vendored
Normal file
1
interfaces/unix/server/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
6
interfaces/unix/server/Cargo.toml
Normal file
6
interfaces/unix/server/Cargo.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "hero-server-unix"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
3
interfaces/unix/server/src/main.rs
Normal file
3
interfaces/unix/server/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
}
|
2
interfaces/websocket/client/.gitignore
vendored
Normal file
2
interfaces/websocket/client/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
/dist
|
94
interfaces/websocket/client/ARCHITECTURE.md
Normal file
94
interfaces/websocket/client/ARCHITECTURE.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# `client_ws` Architecture
|
||||
|
||||
This document details the internal architecture of the `client_ws` crate, focusing on its cross-platform design, internal modules, and the mechanics of its authentication process.
|
||||
|
||||
## 1. Core Design Principles
|
||||
|
||||
The `client_ws` is built on the following principles:
|
||||
|
||||
- **Platform Abstraction**: The core client logic is written in a platform-agnostic way. Platform-specific details (like the WebSocket implementation) are abstracted behind a common interface.
|
||||
- **Modularity**: The crate is divided into logical modules, with a clear separation of concerns between the main client logic, authentication procedures, and cryptographic utilities.
|
||||
- **Asynchronous Operations**: All network I/O is asynchronous, using `async/await` to ensure the client is non-blocking and efficient.
|
||||
- **Fluent Configuration**: A builder pattern (`CircleWsClientBuilder`) is used for clear and flexible client construction.
|
||||
- **Self-Managing Clients**: Each `CircleWsClient` handles its own lifecycle including connection, authentication, keep-alive, and reconnection logic internally.
|
||||
|
||||
## 2. Cross-Platform Implementation
|
||||
|
||||
To support both native and WebAssembly (WASM) environments, `client_ws` uses conditional compilation (`#[cfg]`) to provide different implementations for the underlying WebSocket transport.
|
||||
|
||||
- **Native (`target_arch != "wasm32"`)**: The `tokio-tungstenite` crate is used for robust, `tokio`-based WebSocket communication.
|
||||
- **WebAssembly (`target_arch = "wasm32"`)**: The `gloo-net` crate provides bindings to the browser's native `WebSocket` API.
|
||||
|
||||
This approach allows the `CircleWsClient` to expose a single, unified API while the underlying implementation details are handled transparently at compile time.
|
||||
|
||||
## 3. Module Structure
|
||||
|
||||
The `client_ws` crate is organized into the following key modules:
|
||||
|
||||
- **`lib.rs`**: The main module that defines the `CircleWsClientBuilder` and `CircleWsClient` structs and their public APIs. It orchestrates the entire communication flow.
|
||||
- **`auth/`**: This module contains all the logic related to the `secp256k1` authentication flow.
|
||||
- **`types.rs`**: Defines the core data structures used in authentication, such as `AuthError` and `AuthCredentials`.
|
||||
- **`crypto_utils.rs`**: A self-contained utility module for handling all `secp256k1` cryptographic operations, including key generation, public key derivation, and message signing.
|
||||
|
||||
## 4. Self-Managing Client Architecture
|
||||
|
||||
Each `CircleWsClient` is designed to be completely self-managing, handling its entire lifecycle internally. This includes:
|
||||
|
||||
- **Connection Management**: Establishing and maintaining WebSocket connections
|
||||
- **Authentication**: Automatic secp256k1 authentication flow when private keys are provided
|
||||
- **Keep-Alive**: Periodic health checks to ensure connection stability
|
||||
- **Reconnection**: Automatic reconnection with exponential backoff on connection failures
|
||||
- **Connection Status Tracking**: Internal state management for connection health
|
||||
|
||||
### Connection Flow
|
||||
|
||||
The `connect()` method orchestrates the complete connection and authentication process:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as User Code
|
||||
participant Builder as CircleWsClientBuilder
|
||||
participant Client as CircleWsClient
|
||||
participant CryptoUtils as auth::crypto_utils
|
||||
participant WsActor as Server WebSocket Actor
|
||||
|
||||
User->>+Builder: new(url)
|
||||
User->>+Builder: with_keypair(private_key)
|
||||
User->>+Builder: build()
|
||||
Builder-->>-User: client
|
||||
|
||||
User->>+Client: connect()
|
||||
|
||||
Note over Client: Self-managing connection process
|
||||
Client->>Client: Establish WebSocket connection
|
||||
Client->>Client: Start keep-alive loop
|
||||
Client->>Client: Start reconnection handler
|
||||
|
||||
alt Has Private Key
|
||||
Client->>Client: Check for private_key
|
||||
Client->>+CryptoUtils: derive_public_key(private_key)
|
||||
CryptoUtils-->>-Client: public_key
|
||||
|
||||
Note over Client: Request nonce via WebSocket
|
||||
Client->>+WsActor: JSON-RPC "fetch_nonce" (pubkey)
|
||||
WsActor-->>-Client: JSON-RPC Response (nonce)
|
||||
|
||||
Client->>+CryptoUtils: sign_message(private_key, nonce)
|
||||
CryptoUtils-->>-Client: signature
|
||||
|
||||
Note over Client: Send credentials via WebSocket
|
||||
Client->>+WsActor: JSON-RPC "authenticate" (pubkey, signature)
|
||||
WsActor-->>-Client: JSON-RPC Response (authenticated: true/false)
|
||||
end
|
||||
|
||||
Client-->>-User: Connection established and authenticated
|
||||
```
|
||||
|
||||
### Self-Management Features
|
||||
|
||||
- **Automatic Keep-Alive**: Each client runs its own keep-alive loop to detect connection issues
|
||||
- **Transparent Reconnection**: Failed connections are automatically retried with exponential backoff
|
||||
- **Status Monitoring**: Connection status is tracked internally and can be queried via `is_connected()`
|
||||
- **Resource Cleanup**: Proper cleanup of resources when clients are dropped
|
||||
|
||||
This architecture ensures that the cryptographic operations are isolated, the platform-specific code is cleanly separated, and each client is completely autonomous in managing its connection lifecycle.
|
2764
interfaces/websocket/client/Cargo.lock
generated
Normal file
2764
interfaces/websocket/client/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
56
interfaces/websocket/client/Cargo.toml
Normal file
56
interfaces/websocket/client/Cargo.toml
Normal file
@@ -0,0 +1,56 @@
|
||||
[package]
|
||||
name = "hero_websocket_client"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "hero_websocket_client"
|
||||
path = "cmd/main.rs"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
log = { workspace = true }
|
||||
futures-channel = { workspace = true, features = ["sink"] }
|
||||
futures-util = { workspace = true, features = ["sink"] }
|
||||
thiserror = { workspace = true }
|
||||
url = { workspace = true }
|
||||
http = "0.2"
|
||||
|
||||
# Authentication dependencies
|
||||
hex = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
|
||||
# Optional crypto dependencies (enabled by default)
|
||||
k256 = { version = "0.13", features = ["ecdsa", "sha256"], optional = true }
|
||||
sha3 = { workspace = true, optional = true }
|
||||
|
||||
# WASM-specific dependencies
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
gloo-net = { version = "0.4.0", features = ["websocket"] }
|
||||
gloo-timers = { version = "0.3.0", features = ["futures"] }
|
||||
wasm-bindgen-futures = "0.4"
|
||||
gloo-console = "0.3.0"
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Request", "RequestInit", "RequestMode", "Response", "Window"] }
|
||||
|
||||
# Native-specific dependencies
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tokio-tungstenite = { version = "0.23.1", features = ["native-tls"] }
|
||||
tokio = { workspace = true, features = ["rt", "macros", "time"] }
|
||||
native-tls = "0.2"
|
||||
clap = { workspace = true }
|
||||
env_logger = { workspace = true }
|
||||
dotenv = "0.15"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
|
||||
|
||||
# Features
|
||||
[features]
|
||||
default = ["crypto"]
|
||||
crypto = ["k256", "sha3"]
|
141
interfaces/websocket/client/README.md
Normal file
141
interfaces/websocket/client/README.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Circle WebSocket Client
|
||||
|
||||
A Rust library for connecting to Circle WebSocket servers with authentication support and self-managing connection lifecycle.
|
||||
|
||||
## Features
|
||||
|
||||
- **Cross-platform WebSocket client** (native and WASM)
|
||||
- **secp256k1 cryptographic authentication** with automatic challenge-response flow
|
||||
- **JSON-RPC 2.0 protocol support** for server communication
|
||||
- **Self-managing connections** with automatic keep-alive and reconnection
|
||||
- **Async/await interface** with modern Rust async patterns
|
||||
- **Built on tokio-tungstenite** for reliable WebSocket connections (native)
|
||||
- **Built on gloo-net** for WASM browser compatibility
|
||||
|
||||
## Architecture
|
||||
|
||||
Each `CircleWsClient` is completely self-managing:
|
||||
|
||||
- **Automatic Connection Management**: Handles WebSocket connection establishment
|
||||
- **Built-in Authentication**: Seamless secp256k1 authentication when private keys are provided
|
||||
- **Keep-Alive Monitoring**: Periodic health checks to detect connection issues
|
||||
- **Transparent Reconnection**: Automatic reconnection with exponential backoff on failures
|
||||
- **Connection Status Tracking**: Real-time connection state monitoring
|
||||
|
||||
## Usage
|
||||
|
||||
Add this to your `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
circle_client_ws = { path = "../client_ws" }
|
||||
```
|
||||
|
||||
### Basic Example (Self-Managing Connection)
|
||||
|
||||
```rust
|
||||
use circle_client_ws::CircleWsClientBuilder;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Create client with private key
|
||||
let private_key = "your_private_key_hex";
|
||||
let mut client = CircleWsClientBuilder::new("ws://localhost:8080".to_string())
|
||||
.with_keypair(private_key.to_string())
|
||||
.build();
|
||||
|
||||
// Connect - this handles authentication, keep-alive, and reconnection automatically
|
||||
client.connect().await?;
|
||||
|
||||
// Check connection status
|
||||
println!("Connected: {}", client.is_connected());
|
||||
|
||||
// Execute scripts on the server
|
||||
let result = client.play("\"Hello from client!\"".to_string()).await?;
|
||||
println!("Script result: {:?}", result);
|
||||
|
||||
// Client automatically maintains connection in the background
|
||||
// No manual keep-alive or reconnection logic needed
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Self-Managing Features
|
||||
|
||||
The client automatically handles:
|
||||
|
||||
1. **Connection Establishment**: WebSocket connection to the server
|
||||
2. **Authentication Flow**: secp256k1 challenge-response authentication
|
||||
3. **Keep-Alive Monitoring**: Periodic health checks to ensure connection stability
|
||||
4. **Automatic Reconnection**: Transparent reconnection on connection failures
|
||||
5. **Resource Management**: Proper cleanup when the client is dropped
|
||||
|
||||
### Connection Status Monitoring
|
||||
|
||||
```rust
|
||||
// Check if the client is currently connected
|
||||
if client.is_connected() {
|
||||
println!("Client is connected and healthy");
|
||||
} else {
|
||||
println!("Client is disconnected or reconnecting");
|
||||
}
|
||||
|
||||
// Get detailed connection status
|
||||
let status = client.get_connection_status();
|
||||
println!("Connection status: {}", status);
|
||||
```
|
||||
|
||||
### WASM Usage
|
||||
|
||||
For WASM applications, the client works seamlessly in browsers:
|
||||
|
||||
```rust
|
||||
use circle_client_ws::CircleWsClientBuilder;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
// In a WASM context
|
||||
spawn_local(async move {
|
||||
let mut client = CircleWsClientBuilder::new("ws://localhost:8080".to_string())
|
||||
.build();
|
||||
|
||||
// Self-managing connection works the same in WASM
|
||||
if let Ok(_) = client.connect().await {
|
||||
// Client automatically handles keep-alive and reconnection
|
||||
let result = client.play("\"WASM client connected!\"".to_string()).await;
|
||||
// Handle result...
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Binary Tool
|
||||
|
||||
A command-line binary is also available for interactive use and script execution. See [`cmd/README.md`](cmd/README.md) for details.
|
||||
|
||||
## Platform Support
|
||||
|
||||
- **Native**: Full support on all Rust-supported platforms with tokio-tungstenite
|
||||
- **WASM**: Browser support with gloo-net WebSocket bindings
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Core Dependencies
|
||||
- `serde`: JSON serialization and deserialization
|
||||
- `uuid`: Request ID generation for JSON-RPC
|
||||
- `futures-util`: Async utilities for WebSocket handling
|
||||
- `thiserror`: Error handling and propagation
|
||||
|
||||
### Platform-Specific Dependencies
|
||||
|
||||
#### Native (tokio-based)
|
||||
- `tokio-tungstenite`: Robust WebSocket implementation
|
||||
- `tokio`: Async runtime for connection management
|
||||
|
||||
#### WASM (browser-based)
|
||||
- `gloo-net`: WebSocket bindings for browsers
|
||||
- `gloo-timers`: Timer utilities for keep-alive functionality
|
||||
- `wasm-bindgen-futures`: Async support in WASM
|
||||
|
||||
### Cryptographic Dependencies (optional)
|
||||
- `secp256k1`: Elliptic curve cryptography for authentication
|
||||
- `sha3`: Hashing for cryptographic operations
|
136
interfaces/websocket/client/cmd/README.md
Normal file
136
interfaces/websocket/client/cmd/README.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Circles WebSocket Client
|
||||
|
||||
A WebSocket client for connecting to Circles servers with authentication support. Available in both CLI and WebAssembly (WASM) versions.
|
||||
|
||||
## CLI Usage
|
||||
|
||||
### Installation
|
||||
|
||||
Build the CLI binary:
|
||||
```bash
|
||||
cargo build --bin circles_client --release
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Create a `.env` file in the `cmd/` directory:
|
||||
```bash
|
||||
# cmd/.env
|
||||
PRIVATE_KEY=your_actual_private_key_hex_here
|
||||
```
|
||||
|
||||
Or set the environment variable directly:
|
||||
```bash
|
||||
export PRIVATE_KEY=your_actual_private_key_hex_here
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Basic usage - connects and enters interactive mode
|
||||
circles_client ws://localhost:8080
|
||||
|
||||
# Execute a single Rhai script
|
||||
circles_client -s "print('Hello from Rhai!')" ws://localhost:8080
|
||||
|
||||
# Execute a script from file
|
||||
circles_client -f script.rhai ws://localhost:8080
|
||||
|
||||
# Increase verbosity (can be used multiple times)
|
||||
circles_client -v ws://localhost:8080
|
||||
circles_client -vv ws://localhost:8080
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
- **Authentication**: Automatically loads private key and completes secp256k1 authentication flow
|
||||
- **Script Execution**: Supports both inline scripts (`-s`) and script files (`-f`)
|
||||
- **Interactive Mode**: When no script is provided, enters interactive REPL mode
|
||||
- **Verbosity Control**: Use `-v` flags to increase logging detail
|
||||
- **Cross-platform**: Works on all platforms supported by Rust and tokio-tungstenite
|
||||
|
||||
## WebAssembly (WASM) Usage
|
||||
|
||||
### Build and Serve
|
||||
|
||||
1. Install Trunk:
|
||||
```bash
|
||||
cargo install trunk
|
||||
```
|
||||
|
||||
2. Build the WASM version:
|
||||
```bash
|
||||
trunk build --release
|
||||
```
|
||||
|
||||
3. Serve the application:
|
||||
```bash
|
||||
trunk serve
|
||||
```
|
||||
|
||||
The application will be available at `http://localhost:8080`
|
||||
|
||||
### Usage in Browser
|
||||
|
||||
1. Open the served page in your browser
|
||||
2. Enter the WebSocket server URL
|
||||
3. Choose either:
|
||||
- Execute a Rhai script directly
|
||||
- Enter interactive mode (type 'exit' or 'quit' to leave)
|
||||
|
||||
### Features
|
||||
|
||||
- **Browser Integration**: Uses browser's WebSocket implementation
|
||||
- **Interactive Mode**: Browser-based input/output using prompts
|
||||
- **Error Handling**: Browser console logging
|
||||
- **Cross-browser**: Works in all modern browsers supporting WebAssembly
|
||||
|
||||
## Common Features
|
||||
|
||||
Both versions share the same core functionality:
|
||||
|
||||
- **WebSocket Connection**: Connects to Circles WebSocket server
|
||||
- **Authentication**: Handles secp256k1 authentication
|
||||
- **Script Execution**: Executes Rhai scripts
|
||||
- **Interactive Mode**: Provides REPL-like interface
|
||||
- **Error Handling**: Comprehensive error reporting
|
||||
- **Logging**: Detailed logging at different verbosity levels
|
||||
|
||||
### Interactive Mode
|
||||
|
||||
When run without `-s` or `-f` flags, the client enters interactive mode where you can:
|
||||
- Enter Rhai scripts line by line
|
||||
- Type `exit` or `quit` to close the connection
|
||||
- Use Ctrl+C to terminate
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Connect to local development server
|
||||
circles_client ws://localhost:8080
|
||||
|
||||
# Connect to secure WebSocket with verbose logging
|
||||
circles_client -v wss://circles.example.com/ws
|
||||
|
||||
# Execute a simple calculation
|
||||
circles_client -s "let result = 2 + 2; print(result);" ws://localhost:8080
|
||||
|
||||
# Load and execute a complex script
|
||||
circles_client -f examples/complex_script.rhai ws://localhost:8080
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
The client provides clear error messages for common issues:
|
||||
- Missing or invalid private key
|
||||
- Connection failures
|
||||
- Authentication errors
|
||||
- Script execution errors
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `tokio-tungstenite`: WebSocket client implementation
|
||||
- `secp256k1`: Cryptographic authentication
|
||||
- `clap`: Command-line argument parsing
|
||||
- `env_logger`: Logging infrastructure
|
||||
- `dotenv`: Environment variable loading
|
118
interfaces/websocket/client/cmd/index.html
Normal file
118
interfaces/websocket/client/cmd/index.html
Normal file
@@ -0,0 +1,118 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Circles WebSocket Client</title>
|
||||
<link data-trunk rel="rust" href="../Cargo.toml" data-wasm-opt="z" />
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
margin: 20px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.input-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
input[type="text"] {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
button {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
pre {
|
||||
background: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Circles WebSocket Client</h1>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="ws-url">WebSocket URL:</label>
|
||||
<input type="text" id="ws-url" placeholder="ws://localhost:8080">
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="script">Rhai Script:</label>
|
||||
<input type="text" id="script" placeholder="Enter Rhai script here">
|
||||
</div>
|
||||
|
||||
<button id="run-script">Run Script</button>
|
||||
<button id="run-interactive">Interactive Mode</button>
|
||||
|
||||
<div id="output" style="margin-top: 20px;">
|
||||
<h2>Output:</h2>
|
||||
<pre id="output-content"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
// Trunk will inject the necessary JS to load the WASM module.
|
||||
// The wasm_bindgen functions will be available on the `window` object.
|
||||
async function main() {
|
||||
// The `wasm_bindgen` object is exposed globally by the Trunk-injected script.
|
||||
const { start_client } = wasm_bindgen;
|
||||
|
||||
document.getElementById('run-script').addEventListener('click', async () => {
|
||||
const url = document.getElementById('ws-url').value;
|
||||
const script = document.getElementById('script').value;
|
||||
if (!url) {
|
||||
alert('Please enter a WebSocket URL');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// The init function is called automatically by Trunk's setup.
|
||||
const result = await start_client(url, script);
|
||||
document.getElementById('output-content').textContent = result;
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
document.getElementById('output-content').textContent = `Error: ${error}`;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('run-interactive').addEventListener('click', async () => {
|
||||
const url = document.getElementById('ws-url').value;
|
||||
if (!url) {
|
||||
alert('Please enter a WebSocket URL');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// The init function is called automatically by Trunk's setup.
|
||||
await start_client(url, null);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert(`Error: ${error}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The `wasm_bindgen` function is a promise that resolves when the WASM is loaded.
|
||||
wasm_bindgen('./pkg/circle_client_ws_bg.wasm').then(main).catch(console.error);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
342
interfaces/websocket/client/cmd/main.rs
Normal file
342
interfaces/websocket/client/cmd/main.rs
Normal file
@@ -0,0 +1,342 @@
|
||||
#![cfg_attr(target_arch = "wasm32", no_main)]
|
||||
|
||||
use hero_websocket_client::CircleWsClientBuilder;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::env;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::path::Path;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::io::{self, Write};
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen::prelude::*;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use web_sys::{console, window};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use clap::{Arg, ArgAction, Command};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use dotenv::dotenv;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use env_logger;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use tokio;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use log::{error, info};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Args {
|
||||
ws_url: String,
|
||||
script: Option<String>,
|
||||
script_path: Option<String>,
|
||||
verbose: u8,
|
||||
no_timestamp: bool,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn parse_args() -> Args {
|
||||
let matches = Command::new("circles_client")
|
||||
.version("0.1.0")
|
||||
.about("WebSocket client for Circles server")
|
||||
.arg(
|
||||
Arg::new("url")
|
||||
.help("WebSocket server URL")
|
||||
.required(true)
|
||||
.index(1),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("script")
|
||||
.short('s')
|
||||
.long("script")
|
||||
.value_name("SCRIPT")
|
||||
.help("Rhai script to execute")
|
||||
.conflicts_with("script_path"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("script_path")
|
||||
.short('f')
|
||||
.long("file")
|
||||
.value_name("FILE")
|
||||
.help("Path to Rhai script file")
|
||||
.conflicts_with("script"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("verbose")
|
||||
.short('v')
|
||||
.long("verbose")
|
||||
.help("Increase verbosity (can be used multiple times)")
|
||||
.action(ArgAction::Count),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("no_timestamp")
|
||||
.long("no-timestamp")
|
||||
.help("Remove timestamps from log output")
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.get_matches();
|
||||
|
||||
Args {
|
||||
ws_url: matches.get_one::<String>("url").unwrap().clone(),
|
||||
script: matches.get_one::<String>("script").cloned(),
|
||||
script_path: matches.get_one::<String>("script_path").cloned(),
|
||||
verbose: matches.get_count("verbose"),
|
||||
no_timestamp: matches.get_flag("no_timestamp"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn setup_logging(verbose: u8, no_timestamp: bool) {
|
||||
let log_level = match verbose {
|
||||
0 => "warn,hero_websocket_client=info",
|
||||
1 => "info,hero_websocket_client=debug",
|
||||
2 => "debug",
|
||||
_ => "trace",
|
||||
};
|
||||
|
||||
std::env::set_var("RUST_LOG", log_level);
|
||||
|
||||
// Configure env_logger with or without timestamps
|
||||
if no_timestamp {
|
||||
env_logger::Builder::from_default_env()
|
||||
.format_timestamp(None)
|
||||
.init();
|
||||
} else {
|
||||
env_logger::init();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_private_key() -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Try to load from .env file first
|
||||
if let Ok(_) = dotenv() {
|
||||
if let Ok(key) = env::var("PRIVATE_KEY") {
|
||||
return Ok(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to load from cmd/.env file
|
||||
let cmd_env_path = Path::new("cmd/.env");
|
||||
if cmd_env_path.exists() {
|
||||
dotenv::from_path(cmd_env_path)?;
|
||||
if let Ok(key) = env::var("PRIVATE_KEY") {
|
||||
return Ok(key);
|
||||
}
|
||||
}
|
||||
|
||||
Err("PRIVATE_KEY not found in environment or .env files".into())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
async fn run_interactive_mode(client: hero_websocket_client::CircleWsClient) -> Result<(), Box<dyn std::error::Error>> {
|
||||
console::log_1(&"Entering interactive mode. Type 'exit' or 'quit' to leave.".into());
|
||||
console::log_1(&"🔄 Interactive mode - Enter Rhai scripts (type 'exit' or 'quit' to leave):\n".into());
|
||||
|
||||
// In wasm32, we need to use browser's console for input/output
|
||||
let window = window().expect("Window not available");
|
||||
let input = window.prompt_with_message("Enter Rhai script (or 'exit' to quit):")
|
||||
.map_err(|e| format!("Failed to get input: {:#?}", e))? // Use debug formatting
|
||||
.unwrap_or_default();
|
||||
|
||||
// Handle empty or exit cases
|
||||
if input == "exit" || input == "quit" {
|
||||
console::log_1(&"👋 Goodbye!".into());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Execute the script
|
||||
match client.play(input).await {
|
||||
Ok(result) => {
|
||||
console::log_1(&format!("📤 Result: {}", result.output).into());
|
||||
}
|
||||
Err(e) => {
|
||||
console::log_1(&format!("❌ Script execution failed: {}", e).into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
async fn execute_script(client: hero_websocket_client::CircleWsClient, script: String) -> Result<(), Box<dyn std::error::Error>> {
|
||||
console::log_1(&format!("Executing script: {}", script).into());
|
||||
|
||||
match client.play(script).await {
|
||||
Ok(result) => {
|
||||
console::log_1(&result.output.into());
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
console::log_1(&format!("Script execution failed: {}", e).into());
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn start_client(url: &str, script: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Build client
|
||||
let mut client = CircleWsClientBuilder::new(url.to_string())
|
||||
.build();
|
||||
|
||||
// Connect to WebSocket server
|
||||
console::log_1(&"🔌 Connecting to WebSocket server...".into());
|
||||
if let Err(e) = client.connect().await {
|
||||
console::log_1(&format!("❌ Failed to connect: {}", e).into());
|
||||
return Err(e.into());
|
||||
}
|
||||
console::log_1(&"✅ Connected successfully".into());
|
||||
|
||||
// Authenticate with server
|
||||
if let Err(e) = client.authenticate().await {
|
||||
console::log_1(&format!("❌ Authentication failed: {}", e).into());
|
||||
return Err(e.into());
|
||||
}
|
||||
console::log_1(&"✅ Authentication successful".into());
|
||||
|
||||
// Handle script execution
|
||||
if let Some(script) = script {
|
||||
execute_script(client, script).await
|
||||
} else {
|
||||
run_interactive_mode(client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
async fn execute_script(client: hero_websocket_client::CircleWsClient, script: String) -> Result<(), Box<dyn std::error::Error>> {
|
||||
info!("Executing script: {}", script);
|
||||
|
||||
match client.play(script).await {
|
||||
Ok(result) => {
|
||||
println!("{}", result.output);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Script execution failed: {}", e);
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
async fn load_script_from_file(path: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let script = tokio::fs::read_to_string(path).await?;
|
||||
Ok(script)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
async fn run_interactive_mode(client: hero_websocket_client::CircleWsClient) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("\n🔄 Interactive mode - Enter Rhai scripts (type 'exit' or 'quit' to leave):\n");
|
||||
|
||||
loop {
|
||||
print!("Enter Rhai script (or 'exit' to quit): ");
|
||||
io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
let input = input.trim().to_string();
|
||||
|
||||
if input == "exit" || input == "quit" {
|
||||
println!("\n👋 Goodbye!");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match client.play(input).await {
|
||||
Ok(result) => {
|
||||
println!("\n📤 Result: {}", result.output);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("❌ Script execution failed: {}", e);
|
||||
println!("\n❌ Script execution failed: {}", e);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args = parse_args();
|
||||
setup_logging(args.verbose, args.no_timestamp);
|
||||
|
||||
info!("🚀 Starting Circles WebSocket client");
|
||||
info!("📡 Connecting to: {}", args.ws_url);
|
||||
|
||||
// Load private key from environment
|
||||
let private_key = match load_private_key() {
|
||||
Ok(key) => {
|
||||
info!("🔑 Private key loaded from environment");
|
||||
key
|
||||
}
|
||||
Err(e) => {
|
||||
error!("❌ Failed to load private key: {}", e);
|
||||
eprintln!("Error: {}", e);
|
||||
eprintln!("Please set PRIVATE_KEY in your environment or create a cmd/.env file with:");
|
||||
eprintln!("PRIVATE_KEY=your_private_key_here");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Build client with private key
|
||||
let mut client = CircleWsClientBuilder::new(args.ws_url.clone())
|
||||
.with_keypair(private_key)
|
||||
.build();
|
||||
|
||||
// Connect to WebSocket server
|
||||
info!("🔌 Connecting to WebSocket server...");
|
||||
if let Err(e) = client.connect().await {
|
||||
error!("❌ Failed to connect: {}", e);
|
||||
eprintln!("Connection failed: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
info!("✅ Connected successfully");
|
||||
|
||||
// Authenticate with server
|
||||
info!("🔐 Authenticating with server...");
|
||||
match client.authenticate().await {
|
||||
Ok(true) => {
|
||||
info!("✅ Authentication successful");
|
||||
println!("🔐 Authentication successful");
|
||||
}
|
||||
Ok(false) => {
|
||||
error!("❌ Authentication failed");
|
||||
eprintln!("Authentication failed");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("❌ Authentication error: {}", e);
|
||||
eprintln!("Authentication error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine execution mode
|
||||
let result = if let Some(script) = args.script {
|
||||
// Execute provided script and exit
|
||||
execute_script(client, script).await
|
||||
} else if let Some(script_path) = args.script_path {
|
||||
// Load script from file and execute
|
||||
match load_script_from_file(&script_path).await {
|
||||
Ok(script) => execute_script(client, script).await,
|
||||
Err(e) => {
|
||||
error!("❌ Failed to load script from file '{}': {}", script_path, e);
|
||||
eprintln!("Failed to load script file: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Enter interactive mode
|
||||
run_interactive_mode(client).await
|
||||
};
|
||||
|
||||
// Handle any errors from execution
|
||||
if let Err(e) = result {
|
||||
error!("❌ Execution failed: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
info!("🏁 Client finished successfully");
|
||||
Ok(())
|
||||
}
|
273
interfaces/websocket/client/src/auth/crypto_utils.rs
Normal file
273
interfaces/websocket/client/src/auth/crypto_utils.rs
Normal file
@@ -0,0 +1,273 @@
|
||||
//! Cryptographic utilities for secp256k1 operations
|
||||
//!
|
||||
//! This module provides functions for:
|
||||
//! - Private key validation and parsing
|
||||
//! - Public key derivation
|
||||
//! - Ethereum-style message signing
|
||||
//! - Signature verification
|
||||
|
||||
use crate::auth::types::{AuthError, AuthResult};
|
||||
|
||||
pub fn generate_keypair() -> AuthResult<(String, String)> {
|
||||
let private_key = generate_private_key()?;
|
||||
let public_key = derive_public_key(&private_key)?;
|
||||
Ok((public_key, private_key))
|
||||
}
|
||||
|
||||
/// Generate a new random private key
|
||||
pub fn generate_private_key() -> AuthResult<String> {
|
||||
#[cfg(feature = "crypto")]
|
||||
{
|
||||
use rand::rngs::OsRng;
|
||||
use k256::ecdsa::SigningKey;
|
||||
|
||||
let signing_key = SigningKey::random(&mut OsRng);
|
||||
Ok(hex::encode(signing_key.to_bytes()))
|
||||
}
|
||||
#[cfg(not(feature = "crypto"))]
|
||||
{
|
||||
// Fallback implementation for when crypto features are not available
|
||||
use rand::Rng;
|
||||
let mut rng = rand::thread_rng();
|
||||
let bytes: [u8; 32] = rng.gen();
|
||||
Ok(hex::encode(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a hex-encoded private key
|
||||
pub fn parse_private_key(private_key_hex: &str) -> AuthResult<Vec<u8>> {
|
||||
// Remove 0x prefix if present
|
||||
let clean_hex = private_key_hex
|
||||
.strip_prefix("0x")
|
||||
.unwrap_or(private_key_hex);
|
||||
|
||||
// Decode hex
|
||||
let bytes = hex::decode(clean_hex)
|
||||
.map_err(|e| AuthError::InvalidPrivateKey(format!("Invalid hex: {}", e)))?;
|
||||
|
||||
// Validate length
|
||||
if bytes.len() != 32 {
|
||||
return Err(AuthError::InvalidPrivateKey(format!(
|
||||
"Private key must be 32 bytes, got {}",
|
||||
bytes.len()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Derive public key from private key
|
||||
pub fn derive_public_key(private_key_hex: &str) -> AuthResult<String> {
|
||||
#[cfg(feature = "crypto")]
|
||||
{
|
||||
use k256::ecdsa::SigningKey;
|
||||
use k256::elliptic_curve::sec1::ToEncodedPoint;
|
||||
|
||||
let key_bytes = parse_private_key(private_key_hex)?;
|
||||
let signing_key = SigningKey::from_slice(&key_bytes)
|
||||
.map_err(|e| AuthError::InvalidPrivateKey(format!("Invalid key: {}", e)))?;
|
||||
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
let encoded_point = verifying_key.to_encoded_point(false); // false = uncompressed
|
||||
|
||||
// Return uncompressed public key (65 bytes with 0x04 prefix)
|
||||
Ok(hex::encode(encoded_point.as_bytes()))
|
||||
}
|
||||
#[cfg(not(feature = "crypto"))]
|
||||
{
|
||||
// Fallback implementation - generate a mock public key
|
||||
let key_bytes = parse_private_key(private_key_hex)?;
|
||||
let mut public_key_bytes = vec![0x04u8]; // Uncompressed prefix
|
||||
public_key_bytes.extend_from_slice(&key_bytes);
|
||||
public_key_bytes.extend_from_slice(&key_bytes); // Double for 65 bytes total
|
||||
public_key_bytes.truncate(65);
|
||||
Ok(hex::encode(public_key_bytes))
|
||||
}
|
||||
}
|
||||
|
||||
/// Create Ethereum-style message hash
|
||||
/// This follows the Ethereum standard: keccak256("\x19Ethereum Signed Message:\n" + len(message) + message)
|
||||
fn create_eth_message_hash(message: &str) -> Vec<u8> {
|
||||
let prefix = format!("\x19Ethereum Signed Message:\n{}", message.len());
|
||||
let full_message = format!("{}{}", prefix, message);
|
||||
|
||||
#[cfg(feature = "crypto")]
|
||||
{
|
||||
use sha3::{Digest, Keccak256};
|
||||
let mut hasher = Keccak256::new();
|
||||
hasher.update(full_message.as_bytes());
|
||||
hasher.finalize().to_vec()
|
||||
}
|
||||
#[cfg(not(feature = "crypto"))]
|
||||
{
|
||||
// Fallback: use a simple hash
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
full_message.hash(&mut hasher);
|
||||
let hash = hasher.finish();
|
||||
hash.to_be_bytes().to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign a message using Ethereum-style signing
|
||||
pub fn sign_message(private_key_hex: &str, message: &str) -> AuthResult<String> {
|
||||
#[cfg(feature = "crypto")]
|
||||
{
|
||||
use k256::ecdsa::{SigningKey, signature::Signer};
|
||||
|
||||
let key_bytes = parse_private_key(private_key_hex)?;
|
||||
let signing_key = SigningKey::from_slice(&key_bytes)
|
||||
.map_err(|e| AuthError::InvalidPrivateKey(format!("Invalid private key: {}", e)))?;
|
||||
|
||||
// Create message hash
|
||||
let message_hash = create_eth_message_hash(message);
|
||||
|
||||
// Sign the hash
|
||||
let signature: k256::ecdsa::Signature = signing_key.sign(&message_hash);
|
||||
|
||||
// Convert to recoverable signature format (65 bytes with recovery ID)
|
||||
let sig_bytes = signature.to_bytes();
|
||||
let mut full_sig = [0u8; 65];
|
||||
full_sig[..64].copy_from_slice(&sig_bytes);
|
||||
|
||||
// Calculate recovery ID (simplified - in production you'd want proper recovery)
|
||||
full_sig[64] = 0; // Recovery ID placeholder
|
||||
|
||||
Ok(hex::encode(full_sig))
|
||||
}
|
||||
#[cfg(not(feature = "crypto"))]
|
||||
{
|
||||
// Fallback implementation - generate a mock signature
|
||||
let key_bytes = parse_private_key(private_key_hex)?;
|
||||
let message_hash = create_eth_message_hash(message);
|
||||
|
||||
// Create a deterministic but fake signature
|
||||
let mut sig_bytes = Vec::with_capacity(65);
|
||||
sig_bytes.extend_from_slice(&key_bytes);
|
||||
sig_bytes.extend_from_slice(&message_hash[..32]);
|
||||
sig_bytes.push(27); // Recovery ID
|
||||
sig_bytes.truncate(65);
|
||||
|
||||
Ok(hex::encode(sig_bytes))
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify an Ethereum-style signature
|
||||
pub fn verify_signature(
|
||||
public_key_hex: &str,
|
||||
message: &str,
|
||||
signature_hex: &str,
|
||||
) -> AuthResult<bool> {
|
||||
#[cfg(feature = "crypto")]
|
||||
{
|
||||
use k256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
|
||||
use k256::EncodedPoint;
|
||||
|
||||
// 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);
|
||||
|
||||
// Decode public key
|
||||
let pubkey_bytes = hex::decode(clean_pubkey)
|
||||
.map_err(|e| AuthError::InvalidSignature(format!("Invalid public key hex: {}", e)))?;
|
||||
|
||||
let encoded_point = EncodedPoint::from_bytes(&pubkey_bytes)
|
||||
.map_err(|e| AuthError::InvalidSignature(format!("Invalid public key format: {}", e)))?;
|
||||
|
||||
let verifying_key = VerifyingKey::from_encoded_point(&encoded_point)
|
||||
.map_err(|e| AuthError::InvalidSignature(format!("Invalid public key: {}", e)))?;
|
||||
|
||||
// Decode signature
|
||||
let sig_bytes = hex::decode(clean_sig)
|
||||
.map_err(|e| AuthError::InvalidSignature(format!("Invalid signature hex: {}", e)))?;
|
||||
|
||||
if sig_bytes.len() != 65 {
|
||||
return Err(AuthError::InvalidSignature(format!(
|
||||
"Signature must be 65 bytes, got {}",
|
||||
sig_bytes.len()
|
||||
)));
|
||||
}
|
||||
|
||||
// Extract r, s components (ignore recovery byte for verification)
|
||||
let signature = Signature::from_slice(&sig_bytes[..64])
|
||||
.map_err(|e| AuthError::InvalidSignature(format!("Invalid signature format: {}", e)))?;
|
||||
|
||||
// Create message hash
|
||||
let message_hash = create_eth_message_hash(message);
|
||||
|
||||
// Verify signature
|
||||
match verifying_key.verify(&message_hash, &signature) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "crypto"))]
|
||||
{
|
||||
// Fallback implementation - basic validation
|
||||
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
|
||||
return Err(AuthError::InvalidSignature(
|
||||
"Invalid public key length".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if clean_sig.len() != 130 {
|
||||
// 65 bytes as hex
|
||||
return Err(AuthError::InvalidSignature(
|
||||
"Invalid signature length".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// For app purposes, accept any properly formatted signature
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that a private key is valid
|
||||
pub fn validate_private_key(private_key_hex: &str) -> AuthResult<()> {
|
||||
parse_private_key(private_key_hex)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_key_generation_and_derivation() {
|
||||
let private_key = generate_private_key().unwrap();
|
||||
let public_key = derive_public_key(&private_key).unwrap();
|
||||
|
||||
assert_eq!(private_key.len(), 64); // 32 bytes as hex
|
||||
assert_eq!(public_key.len(), 130); // 65 bytes as hex (uncompressed)
|
||||
assert!(public_key.starts_with("04")); // Uncompressed public key prefix
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signing_and_verification() {
|
||||
let private_key = generate_private_key().unwrap();
|
||||
let public_key = derive_public_key(&private_key).unwrap();
|
||||
let message = "Hello, World!";
|
||||
|
||||
let signature = sign_message(&private_key, message).unwrap();
|
||||
let is_valid = verify_signature(&public_key, message, &signature).unwrap();
|
||||
|
||||
assert!(is_valid);
|
||||
assert_eq!(signature.len(), 130); // 65 bytes as hex
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_private_key() {
|
||||
let result = validate_private_key("invalid_hex");
|
||||
assert!(result.is_err());
|
||||
|
||||
let result = validate_private_key("0x1234"); // Too short
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
113
interfaces/websocket/client/src/auth/mod.rs
Normal file
113
interfaces/websocket/client/src/auth/mod.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
//! Authentication module for Circle WebSocket client
|
||||
//!
|
||||
//! This module provides core cryptographic authentication support for WebSocket connections
|
||||
//! using secp256k1 signatures. It includes:
|
||||
//!
|
||||
//! - **Cryptographic utilities**: Key generation, signing, and verification
|
||||
//! - **Nonce management**: Fetching nonces from authentication servers
|
||||
//! - **Basic types**: Core authentication data structures
|
||||
//!
|
||||
//! ## Features
|
||||
//!
|
||||
//! - **Cross-platform**: Works in both WASM and native environments
|
||||
//! - **Ethereum-compatible**: Uses Ethereum-style message signing
|
||||
//! - **Secure**: Implements proper nonce-based replay protection
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```rust
|
||||
//! use circle_client_ws::auth::{generate_private_key, derive_public_key, sign_message};
|
||||
//! use tokio::runtime::Runtime;
|
||||
//!
|
||||
//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! # let rt = Runtime::new()?;
|
||||
//! # rt.block_on(async {
|
||||
//! // Generate a private key
|
||||
//! let private_key = generate_private_key()?;
|
||||
//!
|
||||
//! // Derive public key from private key
|
||||
//! let public_key = derive_public_key(&private_key)?;
|
||||
//!
|
||||
//! // The nonce would typically be fetched from a server
|
||||
//! let nonce = "some_nonce_from_server";
|
||||
//!
|
||||
//! // Authentication Module
|
||||
//!
|
||||
//! This module handles the client-side authentication flow, including:
|
||||
//! - Fetching a nonce from the server
|
||||
//! - Signing the nonce with a private key
|
||||
//! - Sending the credentials to the server for verification
|
||||
//!
|
||||
//! // Sign the nonce
|
||||
//! let signature = sign_message(&private_key, nonce)?;
|
||||
//! # Ok(())
|
||||
//! # })
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
pub mod types;
|
||||
pub use types::{AuthCredentials, AuthError, AuthResult, NonceResponse};
|
||||
|
||||
pub mod crypto_utils;
|
||||
pub use crypto_utils::{
|
||||
derive_public_key, generate_keypair, generate_private_key, parse_private_key, sign_message,
|
||||
validate_private_key, verify_signature,
|
||||
};
|
||||
|
||||
/// Check if the authentication feature is enabled
|
||||
///
|
||||
/// This function can be used to conditionally enable authentication features
|
||||
/// based on compile-time feature flags.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` if crypto features are available, `false` otherwise
|
||||
pub fn is_auth_enabled() -> bool {
|
||||
cfg!(feature = "crypto")
|
||||
}
|
||||
|
||||
/// Get version information for the authentication module
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A string containing version and feature information
|
||||
pub fn auth_version_info() -> String {
|
||||
let crypto_status = if cfg!(feature = "crypto") {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled (fallback mode)"
|
||||
};
|
||||
|
||||
let platform = if cfg!(target_arch = "wasm32") {
|
||||
"WASM"
|
||||
} else {
|
||||
"native"
|
||||
};
|
||||
|
||||
format!(
|
||||
"circles-client-ws auth module - crypto: {}, platform: {}",
|
||||
crypto_status, platform
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_module_exports() {
|
||||
// Test utility functions
|
||||
assert!(auth_version_info().contains("circles-client-ws auth module"));
|
||||
|
||||
// Test feature detection
|
||||
let _is_enabled = is_auth_enabled();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_info() {
|
||||
let version = auth_version_info();
|
||||
assert!(version.contains("circles-client-ws auth module"));
|
||||
assert!(version.contains("crypto:"));
|
||||
assert!(version.contains("platform:"));
|
||||
}
|
||||
}
|
128
interfaces/websocket/client/src/auth/types.rs
Normal file
128
interfaces/websocket/client/src/auth/types.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
//! Authentication types for Circle WebSocket client
|
||||
//!
|
||||
//! This module defines the core types used in the authentication system,
|
||||
//! including error types, response structures, and authentication states.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Result type for authentication operations
|
||||
pub type AuthResult<T> = Result<T, AuthError>;
|
||||
|
||||
/// Authentication error types
|
||||
#[derive(Error, Debug, Clone)]
|
||||
pub enum AuthError {
|
||||
#[error("Invalid private key: {0}")]
|
||||
InvalidPrivateKey(String),
|
||||
#[error("Invalid URL: {0}")]
|
||||
InvalidUrl(String),
|
||||
#[error("Nonce request failed: {0}")]
|
||||
NonceRequestFailed(String),
|
||||
#[error("Signing failed: {0}")]
|
||||
SigningFailed(String),
|
||||
#[error("Network error: {0}")]
|
||||
NetworkError(String),
|
||||
#[error("Invalid signature: {0}")]
|
||||
InvalidSignature(String),
|
||||
#[error("Invalid credentials: {0}")]
|
||||
InvalidCredentials(String),
|
||||
}
|
||||
|
||||
/// Response from nonce endpoint
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct NonceResponse {
|
||||
/// The cryptographic nonce
|
||||
pub nonce: String,
|
||||
/// Expiration timestamp (seconds since epoch)
|
||||
pub expires_at: u64,
|
||||
}
|
||||
|
||||
/// Authentication credentials for WebSocket connection
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthCredentials {
|
||||
/// Public key in hex format
|
||||
pub public_key: String,
|
||||
/// Signature of the nonce
|
||||
pub signature: String,
|
||||
/// Nonce that was signed
|
||||
pub nonce: String,
|
||||
/// Expiration timestamp (seconds since epoch)
|
||||
pub expires_at: u64,
|
||||
}
|
||||
|
||||
impl AuthCredentials {
|
||||
/// Create new authentication credentials
|
||||
pub fn new(public_key: String, signature: String, nonce: String, expires_at: u64) -> Self {
|
||||
Self {
|
||||
public_key,
|
||||
signature,
|
||||
nonce,
|
||||
expires_at,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the public key
|
||||
pub fn public_key(&self) -> &str {
|
||||
&self.public_key
|
||||
}
|
||||
|
||||
/// Get the signature
|
||||
pub fn signature(&self) -> &str {
|
||||
&self.signature
|
||||
}
|
||||
|
||||
/// Get the nonce
|
||||
pub fn nonce(&self) -> &str {
|
||||
&self.nonce
|
||||
}
|
||||
|
||||
/// Check if credentials have expired
|
||||
pub fn is_expired(&self) -> bool {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
if let Ok(current_time) = SystemTime::now().duration_since(UNIX_EPOCH) {
|
||||
let current_timestamp = current_time.as_secs();
|
||||
current_timestamp >= self.expires_at
|
||||
} else {
|
||||
true // If we can't get current time, assume expired for safety
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if credentials expire within the given number of seconds
|
||||
pub fn expires_within(&self, seconds: u64) -> bool {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
if let Ok(current_time) = SystemTime::now().duration_since(UNIX_EPOCH) {
|
||||
let current_timestamp = current_time.as_secs();
|
||||
self.expires_at <= current_timestamp + seconds
|
||||
} else {
|
||||
true // If we can't get current time, assume expiring soon for safety
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authentication state for tracking connection status
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AuthState {
|
||||
/// Not authenticated
|
||||
NotAuthenticated,
|
||||
/// Currently authenticating
|
||||
Authenticating,
|
||||
/// Successfully authenticated
|
||||
Authenticated { public_key: String },
|
||||
/// Authentication failed
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Authentication method used
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AuthMethod {
|
||||
/// Private key authentication
|
||||
PrivateKey,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AuthMethod {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AuthMethod::PrivateKey => write!(f, "Private Key"),
|
||||
}
|
||||
}
|
||||
}
|
994
interfaces/websocket/client/src/lib.rs
Normal file
994
interfaces/websocket/client/src/lib.rs
Normal file
@@ -0,0 +1,994 @@
|
||||
use futures_channel::{mpsc, oneshot};
|
||||
use futures_util::{FutureExt, SinkExt, StreamExt};
|
||||
use log::{debug, error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Authentication module
|
||||
pub mod auth;
|
||||
|
||||
pub use auth::{AuthCredentials, AuthError, AuthResult};
|
||||
|
||||
// Platform-specific WebSocket imports and spawn function
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use {
|
||||
gloo_net::websocket::{futures::WebSocket, Message as GlooWsMessage},
|
||||
wasm_bindgen_futures::spawn_local,
|
||||
};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use {
|
||||
tokio::spawn as spawn_local,
|
||||
tokio_tungstenite::{
|
||||
connect_async, connect_async_tls_with_config,
|
||||
tungstenite::{
|
||||
protocol::Message as TungsteniteWsMessage,
|
||||
},
|
||||
Connector,
|
||||
},
|
||||
};
|
||||
|
||||
// JSON-RPC Structures (client-side perspective)
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub struct JsonRpcRequestClient {
|
||||
jsonrpc: String,
|
||||
method: String,
|
||||
params: Value,
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct JsonRpcResponseClient {
|
||||
#[allow(dead_code)]
|
||||
// Field is part of JSON-RPC spec, even if not directly used by client logic
|
||||
jsonrpc: String,
|
||||
pub result: Option<Value>,
|
||||
pub error: Option<JsonRpcErrorClient>,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct JsonRpcErrorClient {
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
pub data: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub struct PlayParamsClient {
|
||||
pub script: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct PlayResultClient {
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub struct AuthCredentialsParams {
|
||||
pub pubkey: String,
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub struct FetchNonceParams {
|
||||
pub pubkey: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct FetchNonceResponse {
|
||||
pub nonce: String,
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum CircleWsClientError {
|
||||
#[error("WebSocket connection error: {0}")]
|
||||
ConnectionError(String),
|
||||
#[error("WebSocket send error: {0}")]
|
||||
SendError(String),
|
||||
#[error("WebSocket receive error: {0}")]
|
||||
ReceiveError(String),
|
||||
#[error("JSON serialization/deserialization error: {0}")]
|
||||
JsonError(#[from] serde_json::Error),
|
||||
#[error("Request timed out for request ID: {0}")]
|
||||
Timeout(String),
|
||||
#[error("JSON-RPC error response: {code} - {message}")]
|
||||
JsonRpcError {
|
||||
code: i32,
|
||||
message: String,
|
||||
data: Option<Value>,
|
||||
},
|
||||
#[error("No response received for request ID: {0}")]
|
||||
NoResponse(String),
|
||||
#[error("Client is not connected")]
|
||||
NotConnected,
|
||||
#[error("Internal channel error: {0}")]
|
||||
ChannelError(String),
|
||||
#[error("Authentication error: {0}")]
|
||||
Auth(#[from] auth::AuthError),
|
||||
#[error("Authentication requires a keypair, but none was provided.")]
|
||||
AuthNoKeyPair,
|
||||
}
|
||||
|
||||
// Wrapper for messages sent to the WebSocket task
|
||||
enum InternalWsMessage {
|
||||
SendJsonRpc(
|
||||
JsonRpcRequestClient,
|
||||
oneshot::Sender<Result<JsonRpcResponseClient, CircleWsClientError>>,
|
||||
),
|
||||
SendPlaintext(
|
||||
String,
|
||||
oneshot::Sender<Result<String, CircleWsClientError>>,
|
||||
),
|
||||
Close,
|
||||
}
|
||||
|
||||
pub struct CircleWsClientBuilder {
|
||||
ws_url: String,
|
||||
private_key: Option<String>,
|
||||
}
|
||||
|
||||
impl CircleWsClientBuilder {
|
||||
pub fn new(ws_url: String) -> Self {
|
||||
Self {
|
||||
ws_url,
|
||||
private_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_keypair(mut self, private_key: String) -> Self {
|
||||
self.private_key = Some(private_key);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> CircleWsClient {
|
||||
CircleWsClient {
|
||||
ws_url: self.ws_url,
|
||||
internal_tx: None,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
task_handle: None,
|
||||
private_key: self.private_key,
|
||||
is_connected: Arc::new(Mutex::new(false)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CircleWsClient {
|
||||
ws_url: String,
|
||||
internal_tx: Option<mpsc::Sender<InternalWsMessage>>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
task_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
private_key: Option<String>,
|
||||
is_connected: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl CircleWsClient {
|
||||
/// Get the connection status
|
||||
pub fn get_connection_status(&self) -> String {
|
||||
if *self.is_connected.lock().unwrap() {
|
||||
"Connected".to_string()
|
||||
} else {
|
||||
"Disconnected".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the client is connected
|
||||
pub fn is_connected(&self) -> bool {
|
||||
*self.is_connected.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl CircleWsClient {
|
||||
pub async fn authenticate(&mut self) -> Result<bool, CircleWsClientError> {
|
||||
info!("🔐 [{}] Starting authentication process...", self.ws_url);
|
||||
|
||||
let private_key = self
|
||||
.private_key
|
||||
.as_ref()
|
||||
.ok_or(CircleWsClientError::AuthNoKeyPair)?;
|
||||
|
||||
info!("🔑 [{}] Deriving public key from private key...", self.ws_url);
|
||||
let public_key = auth::derive_public_key(private_key)?;
|
||||
info!("✅ [{}] Public key derived: {}...", self.ws_url, &public_key[..8]);
|
||||
|
||||
info!("🎫 [{}] Fetching authentication nonce...", self.ws_url);
|
||||
let nonce = self.fetch_nonce(&public_key).await?;
|
||||
info!("✅ [{}] Nonce received: {}...", self.ws_url, &nonce[..8]);
|
||||
|
||||
info!("✍️ [{}] Signing nonce with private key...", self.ws_url);
|
||||
let signature = auth::sign_message(private_key, &nonce)?;
|
||||
info!("✅ [{}] Signature created: {}...", self.ws_url, &signature[..8]);
|
||||
|
||||
info!("🔒 [{}] Submitting authentication credentials...", self.ws_url);
|
||||
let result = self.authenticate_with_signature(&public_key, &signature).await?;
|
||||
|
||||
if result {
|
||||
info!("🎉 [{}] Authentication successful!", self.ws_url);
|
||||
} else {
|
||||
error!("❌ [{}] Authentication failed - server rejected credentials", self.ws_url);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn fetch_nonce(&self, pubkey: &str) -> Result<String, CircleWsClientError> {
|
||||
info!("📡 [{}] Sending fetch_nonce request for pubkey: {}...", self.ws_url, &pubkey[..8]);
|
||||
|
||||
let params = FetchNonceParams {
|
||||
pubkey: pubkey.to_string(),
|
||||
};
|
||||
let req = self.create_request("fetch_nonce", params)?;
|
||||
let res = self.send_request(req).await?;
|
||||
|
||||
if let Some(err) = res.error {
|
||||
error!("❌ [{}] fetch_nonce failed: {} (code: {})", self.ws_url, err.message, err.code);
|
||||
return Err(CircleWsClientError::JsonRpcError {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
data: err.data,
|
||||
});
|
||||
}
|
||||
|
||||
let nonce_res: FetchNonceResponse = serde_json::from_value(res.result.unwrap_or_default())?;
|
||||
info!("✅ [{}] fetch_nonce successful, nonce length: {}", self.ws_url, nonce_res.nonce.len());
|
||||
Ok(nonce_res.nonce)
|
||||
}
|
||||
|
||||
async fn authenticate_with_signature(
|
||||
&self,
|
||||
pubkey: &str,
|
||||
signature: &str,
|
||||
) -> Result<bool, CircleWsClientError> {
|
||||
info!("📡 [{}] Sending authenticate request with signature...", self.ws_url);
|
||||
|
||||
let params = AuthCredentialsParams {
|
||||
pubkey: pubkey.to_string(),
|
||||
signature: signature.to_string(),
|
||||
};
|
||||
let req = self.create_request("authenticate", params)?;
|
||||
let res = self.send_request(req).await?;
|
||||
|
||||
if let Some(err) = res.error {
|
||||
error!("❌ [{}] authenticate failed: {} (code: {})", self.ws_url, err.message, err.code);
|
||||
return Err(CircleWsClientError::JsonRpcError {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
data: err.data,
|
||||
});
|
||||
}
|
||||
|
||||
let authenticated = res
|
||||
.result
|
||||
.and_then(|v| v.get("authenticated").and_then(|v| v.as_bool()))
|
||||
.unwrap_or(false);
|
||||
|
||||
if authenticated {
|
||||
info!("✅ [{}] authenticate request successful - server confirmed authentication", self.ws_url);
|
||||
} else {
|
||||
error!("❌ [{}] authenticate request failed - server returned false", self.ws_url);
|
||||
}
|
||||
|
||||
Ok(authenticated)
|
||||
}
|
||||
|
||||
/// Call the whoami method to get authentication status and user information
|
||||
pub async fn whoami(&self) -> Result<Value, CircleWsClientError> {
|
||||
let req = self.create_request("whoami", serde_json::json!({}))?;
|
||||
let response = self.send_request(req).await?;
|
||||
|
||||
if let Some(result) = response.result {
|
||||
Ok(result)
|
||||
} else if let Some(error) = response.error {
|
||||
Err(CircleWsClientError::JsonRpcError {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
data: error.data,
|
||||
})
|
||||
} else {
|
||||
Err(CircleWsClientError::NoResponse("whoami".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn create_request<T: Serialize>(
|
||||
&self,
|
||||
method: &str,
|
||||
params: T,
|
||||
) -> Result<JsonRpcRequestClient, CircleWsClientError> {
|
||||
Ok(JsonRpcRequestClient {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
method: method.to_string(),
|
||||
params: serde_json::to_value(params)?,
|
||||
id: Uuid::new_v4().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn send_request(
|
||||
&self,
|
||||
req: JsonRpcRequestClient,
|
||||
) -> Result<JsonRpcResponseClient, CircleWsClientError> {
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
if let Some(mut tx) = self.internal_tx.clone() {
|
||||
tx.send(InternalWsMessage::SendJsonRpc(req.clone(), response_tx))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
CircleWsClientError::ChannelError(format!(
|
||||
"Failed to send request to internal task: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
} else {
|
||||
return Err(CircleWsClientError::NotConnected);
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
match response_rx.await {
|
||||
Ok(Ok(rpc_response)) => Ok(rpc_response),
|
||||
Ok(Err(e)) => Err(e),
|
||||
Err(_) => Err(CircleWsClientError::Timeout(req.id)),
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use tokio::time::timeout as tokio_timeout;
|
||||
match tokio_timeout(std::time::Duration::from_secs(30), response_rx).await {
|
||||
Ok(Ok(Ok(rpc_response))) => Ok(rpc_response),
|
||||
Ok(Ok(Err(e))) => Err(e),
|
||||
Ok(Err(_)) => Err(CircleWsClientError::ChannelError(
|
||||
"Response channel cancelled".to_string(),
|
||||
)),
|
||||
Err(_) => Err(CircleWsClientError::Timeout(req.id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn connect(&mut self) -> Result<(), CircleWsClientError> {
|
||||
if self.internal_tx.is_some() {
|
||||
info!("🔄 [{}] Client already connected or connecting", self.ws_url);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("🚀 [{}] Starting self-managed WebSocket connection with keep-alive and reconnection...", self.ws_url);
|
||||
let (internal_tx, internal_rx) = mpsc::channel::<InternalWsMessage>(32);
|
||||
self.internal_tx = Some(internal_tx);
|
||||
|
||||
// Clone necessary data for the task
|
||||
let connection_url = self.ws_url.clone();
|
||||
let private_key = self.private_key.clone();
|
||||
let is_connected = self.is_connected.clone();
|
||||
info!("🔗 [{}] Will handle connection, authentication, keep-alive, and reconnection internally", connection_url);
|
||||
|
||||
// Pending requests: map request_id to a oneshot sender for the response
|
||||
let pending_requests: Arc<
|
||||
Mutex<
|
||||
HashMap<
|
||||
String,
|
||||
oneshot::Sender<Result<JsonRpcResponseClient, CircleWsClientError>>,
|
||||
>,
|
||||
>,
|
||||
> = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
let task_pending_requests = pending_requests.clone();
|
||||
let log_url = connection_url.clone();
|
||||
|
||||
let task = async move {
|
||||
// Main connection loop with reconnection logic
|
||||
loop {
|
||||
info!("🔄 [{}] Starting connection attempt...", log_url);
|
||||
|
||||
// Reset connection status
|
||||
*is_connected.lock().unwrap() = false;
|
||||
|
||||
// Clone connection_url for this iteration to avoid move issues
|
||||
let connection_url_clone = connection_url.clone();
|
||||
|
||||
// Establish WebSocket connection
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let ws_result = WebSocket::open(&connection_url_clone);
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let connect_attempt = async {
|
||||
// Check if this is a secure WebSocket connection
|
||||
if connection_url_clone.starts_with("wss://") {
|
||||
// For WSS connections, use a custom TLS connector that accepts self-signed certificates
|
||||
// This is for development/demo purposes only
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
|
||||
let request = connection_url_clone.as_str().into_client_request()
|
||||
.map_err(|e| CircleWsClientError::ConnectionError(format!("Invalid URL: {}", e)))?;
|
||||
|
||||
// Create a native-tls connector that accepts invalid certificates (for development)
|
||||
let tls_connector = native_tls::TlsConnector::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
.build()
|
||||
.map_err(|e| CircleWsClientError::ConnectionError(format!("TLS connector creation failed: {}", e)))?;
|
||||
|
||||
let connector = Connector::NativeTls(tls_connector);
|
||||
|
||||
warn!("⚠️ DEVELOPMENT MODE: Accepting self-signed certificates (NOT for production!)");
|
||||
connect_async_tls_with_config(request, None, false, Some(connector))
|
||||
.await
|
||||
.map_err(|e| CircleWsClientError::ConnectionError(format!("WSS connection failed: {}", e)))
|
||||
} else {
|
||||
// For regular WS connections, use the standard method
|
||||
connect_async(&connection_url_clone)
|
||||
.await
|
||||
.map_err(|e| CircleWsClientError::ConnectionError(format!("WS connection failed: {}", e)))
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let ws_result = connect_attempt.await;
|
||||
|
||||
match ws_result {
|
||||
Ok(ws_conn_maybe_response) => {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let ws_conn = ws_conn_maybe_response;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let (ws_conn, _) = ws_conn_maybe_response;
|
||||
|
||||
// For WASM, WebSocket::open() always succeeds even if server is down
|
||||
// We'll start as "connecting" and detect failures through timeouts
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
info!("🔄 [{}] WebSocket object created, testing actual connectivity...", log_url);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
info!("✅ [{}] WebSocket connection established successfully", log_url);
|
||||
*is_connected.lock().unwrap() = true;
|
||||
}
|
||||
|
||||
// Handle authentication if private key is provided
|
||||
let auth_success = if let Some(ref _pk) = private_key {
|
||||
info!("🔐 [{}] Authentication will be handled by separate authenticate() call", log_url);
|
||||
true // For now, assume auth will be handled separately
|
||||
} else {
|
||||
info!("ℹ️ [{}] No private key provided, skipping authentication", log_url);
|
||||
true
|
||||
};
|
||||
|
||||
if auth_success {
|
||||
// Start the main message handling loop with keep-alive
|
||||
let disconnect_reason = Self::handle_connection_with_keepalive(
|
||||
ws_conn,
|
||||
internal_rx,
|
||||
&task_pending_requests,
|
||||
&log_url,
|
||||
&is_connected
|
||||
).await;
|
||||
|
||||
info!("🔌 [{}] Connection ended: {}", log_url, disconnect_reason);
|
||||
|
||||
// Check if this was a manual disconnect
|
||||
if disconnect_reason == "Manual close requested" {
|
||||
break; // Don't reconnect on manual close
|
||||
}
|
||||
|
||||
// If we reach here, we need to recreate internal_rx for the next iteration
|
||||
// But since internal_rx was moved, we need to break out of the loop
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("❌ [{}] WebSocket connection failed: {:?}", log_url, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset connection status
|
||||
*is_connected.lock().unwrap() = false;
|
||||
|
||||
// Wait before reconnecting
|
||||
info!("⏳ [{}] Waiting 5 seconds before reconnection attempt...", log_url);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
use gloo_timers::future::TimeoutFuture;
|
||||
TimeoutFuture::new(5_000).await;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup pending requests on exit
|
||||
task_pending_requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.drain()
|
||||
.for_each(|(_, sender)| {
|
||||
let _ = sender.send(Err(CircleWsClientError::ConnectionError(
|
||||
"WebSocket task terminated".to_string(),
|
||||
)));
|
||||
});
|
||||
|
||||
info!("🏁 [{}] WebSocket task finished", log_url);
|
||||
};
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
spawn_local(task);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.task_handle = Some(spawn_local(task));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Enhanced connection loop handler with keep-alive
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
async fn handle_connection_with_keepalive(
|
||||
ws_conn: WebSocket,
|
||||
mut internal_rx: mpsc::Receiver<InternalWsMessage>,
|
||||
pending_requests: &Arc<Mutex<HashMap<String, oneshot::Sender<Result<JsonRpcResponseClient, CircleWsClientError>>>>>,
|
||||
log_url: &str,
|
||||
is_connected: &Arc<Mutex<bool>>,
|
||||
) -> String {
|
||||
let (mut ws_tx, mut ws_rx) = ws_conn.split();
|
||||
let mut internal_rx_fused = internal_rx.fuse();
|
||||
|
||||
// Track plaintext requests (like ping)
|
||||
let pending_plaintext: Arc<Mutex<HashMap<String, oneshot::Sender<Result<String, CircleWsClientError>>>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
// Connection validation for WASM - test if connection actually works
|
||||
let mut connection_test_timer = TimeoutFuture::new(2_000).fuse(); // 2 second timeout
|
||||
let mut connection_validated = false;
|
||||
|
||||
// Keep-alive timer - send ping every 30 seconds
|
||||
use gloo_timers::future::TimeoutFuture;
|
||||
let mut keep_alive_timer = TimeoutFuture::new(30_000).fuse();
|
||||
|
||||
// Send initial connection test ping
|
||||
debug!("Sending initial connection test ping to {}", log_url);
|
||||
let test_ping_res = ws_tx.send(GlooWsMessage::Text("ping".to_string())).await;
|
||||
if let Err(e) = test_ping_res {
|
||||
error!("❌ [{}] Initial connection test failed: {:?}", log_url, e);
|
||||
*is_connected.lock().unwrap() = false;
|
||||
return format!("Initial connection test failed: {}", e);
|
||||
}
|
||||
|
||||
loop {
|
||||
futures_util::select! {
|
||||
// Connection test timeout - if no response in 2 seconds, connection failed
|
||||
_ = connection_test_timer => {
|
||||
if !connection_validated {
|
||||
error!("❌ [{}] Connection test failed - no response within 2 seconds", log_url);
|
||||
*is_connected.lock().unwrap() = false;
|
||||
return "Connection test timeout - server not responding".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle messages from the client's public methods (e.g., play)
|
||||
internal_msg = internal_rx_fused.next().fuse() => {
|
||||
match internal_msg {
|
||||
Some(InternalWsMessage::SendJsonRpc(req, response_sender)) => {
|
||||
let req_id = req.id.clone();
|
||||
match serde_json::to_string(&req) {
|
||||
Ok(req_str) => {
|
||||
debug!("Sending JSON-RPC request (ID: {}): {}", req_id, req_str);
|
||||
let send_res = ws_tx.send(GlooWsMessage::Text(req_str)).await;
|
||||
if let Err(e) = send_res {
|
||||
error!("WebSocket send error for request ID {}: {:?}", req_id, e);
|
||||
// Connection failed - update status
|
||||
*is_connected.lock().unwrap() = false;
|
||||
let _ = response_sender.send(Err(CircleWsClientError::SendError(e.to_string())));
|
||||
} else {
|
||||
// Store the sender to await the response
|
||||
pending_requests.lock().unwrap().insert(req_id, response_sender);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to serialize request ID {}: {}", req_id, e);
|
||||
let _ = response_sender.send(Err(CircleWsClientError::JsonError(e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(InternalWsMessage::SendPlaintext(text, response_sender)) => {
|
||||
debug!("Sending plaintext message: {}", text);
|
||||
let send_res = ws_tx.send(GlooWsMessage::Text(text.clone())).await;
|
||||
if let Err(e) = send_res {
|
||||
error!("WebSocket send error for plaintext message: {:?}", e);
|
||||
*is_connected.lock().unwrap() = false;
|
||||
let _ = response_sender.send(Err(CircleWsClientError::SendError(e.to_string())));
|
||||
} else {
|
||||
// For plaintext messages like ping, we expect an immediate response
|
||||
// Store the response sender to await the response (e.g., pong)
|
||||
let request_id = format!("plaintext_{}", uuid::Uuid::new_v4());
|
||||
pending_plaintext.lock().unwrap().insert(request_id, response_sender);
|
||||
}
|
||||
}
|
||||
Some(InternalWsMessage::Close) => {
|
||||
info!("Close message received internally, closing WebSocket.");
|
||||
let _ = ws_tx.close().await;
|
||||
return "Manual close requested".to_string();
|
||||
}
|
||||
None => {
|
||||
info!("Internal MPSC channel closed, WebSocket task shutting down.");
|
||||
let _ = ws_tx.close().await;
|
||||
return "Internal channel closed".to_string();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Handle messages received from the WebSocket server
|
||||
ws_msg_res = ws_rx.next().fuse() => {
|
||||
match ws_msg_res {
|
||||
Some(Ok(msg)) => {
|
||||
// Any successful message confirms the connection is working
|
||||
if !connection_validated {
|
||||
info!("✅ [{}] WebSocket connection validated - received message from server", log_url);
|
||||
*is_connected.lock().unwrap() = true;
|
||||
connection_validated = true;
|
||||
}
|
||||
|
||||
match msg {
|
||||
GlooWsMessage::Text(text) => {
|
||||
debug!("Received WebSocket message: {}", text);
|
||||
Self::handle_received_message(&text, pending_requests, &pending_plaintext);
|
||||
}
|
||||
GlooWsMessage::Bytes(_) => {
|
||||
debug!("Received binary WebSocket message (WASM).");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
error!("WebSocket receive error: {:?}", e);
|
||||
*is_connected.lock().unwrap() = false;
|
||||
return format!("Receive error: {}", e);
|
||||
}
|
||||
None => {
|
||||
info!("WebSocket connection closed by server (stream ended).");
|
||||
*is_connected.lock().unwrap() = false;
|
||||
return "Server closed connection (stream ended)".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep-alive timer - send ping every 30 seconds
|
||||
_ = keep_alive_timer => {
|
||||
// Only send ping if connection is validated
|
||||
if connection_validated {
|
||||
debug!("Sending keep-alive ping to {}", log_url);
|
||||
let ping_str = "ping"; // Send simple plaintext ping
|
||||
|
||||
let send_res = ws_tx.send(GlooWsMessage::Text(ping_str.to_string())).await;
|
||||
if let Err(e) = send_res {
|
||||
warn!("Keep-alive ping failed for {}: {:?}", log_url, e);
|
||||
*is_connected.lock().unwrap() = false;
|
||||
return format!("Keep-alive failed: {}", e);
|
||||
}
|
||||
} else {
|
||||
debug!("Skipping keep-alive ping - connection not yet validated for {}", log_url);
|
||||
}
|
||||
|
||||
// Reset timer
|
||||
keep_alive_timer = TimeoutFuture::new(30_000).fuse();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced connection loop handler with keep-alive for native targets
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
async fn handle_connection_with_keepalive(
|
||||
ws_conn: tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
|
||||
mut internal_rx: mpsc::Receiver<InternalWsMessage>,
|
||||
pending_requests: &Arc<Mutex<HashMap<String, oneshot::Sender<Result<JsonRpcResponseClient, CircleWsClientError>>>>>,
|
||||
log_url: &str,
|
||||
_is_connected: &Arc<Mutex<bool>>,
|
||||
) -> String {
|
||||
let (mut ws_tx, mut ws_rx) = ws_conn.split();
|
||||
let mut internal_rx_fused = internal_rx.fuse();
|
||||
|
||||
// Track plaintext requests (like ping)
|
||||
let pending_plaintext: Arc<Mutex<HashMap<String, oneshot::Sender<Result<String, CircleWsClientError>>>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
loop {
|
||||
futures_util::select! {
|
||||
// Handle messages from the client's public methods (e.g., play)
|
||||
internal_msg = internal_rx_fused.next().fuse() => {
|
||||
match internal_msg {
|
||||
Some(InternalWsMessage::SendJsonRpc(req, response_sender)) => {
|
||||
let req_id = req.id.clone();
|
||||
match serde_json::to_string(&req) {
|
||||
Ok(req_str) => {
|
||||
debug!("Sending JSON-RPC request (ID: {}): {}", req_id, req_str);
|
||||
let send_res = ws_tx.send(TungsteniteWsMessage::Text(req_str)).await;
|
||||
if let Err(e) = send_res {
|
||||
error!("WebSocket send error for request ID {}: {:?}", req_id, e);
|
||||
let _ = response_sender.send(Err(CircleWsClientError::SendError(e.to_string())));
|
||||
} else {
|
||||
// Store the sender to await the response
|
||||
pending_requests.lock().unwrap().insert(req_id, response_sender);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to serialize request ID {}: {}", req_id, e);
|
||||
let _ = response_sender.send(Err(CircleWsClientError::JsonError(e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(InternalWsMessage::SendPlaintext(text, response_sender)) => {
|
||||
debug!("Sending plaintext message: {}", text);
|
||||
let send_res = ws_tx.send(TungsteniteWsMessage::Text(text.clone())).await;
|
||||
if let Err(e) = send_res {
|
||||
error!("WebSocket send error for plaintext message: {:?}", e);
|
||||
let _ = response_sender.send(Err(CircleWsClientError::SendError(e.to_string())));
|
||||
} else {
|
||||
// For plaintext messages like ping, we expect an immediate response
|
||||
// Store the response sender to await the response (e.g., pong)
|
||||
let request_id = format!("plaintext_{}", uuid::Uuid::new_v4());
|
||||
pending_plaintext.lock().unwrap().insert(request_id, response_sender);
|
||||
}
|
||||
}
|
||||
Some(InternalWsMessage::Close) => {
|
||||
info!("Close message received internally, closing WebSocket.");
|
||||
let _ = ws_tx.close().await;
|
||||
return "Manual close requested".to_string();
|
||||
}
|
||||
None => {
|
||||
info!("Internal MPSC channel closed, WebSocket task shutting down.");
|
||||
let _ = ws_tx.close().await;
|
||||
return "Internal channel closed".to_string();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Handle messages received from the WebSocket server
|
||||
ws_msg_res = ws_rx.next().fuse() => {
|
||||
match ws_msg_res {
|
||||
Some(Ok(msg)) => {
|
||||
match msg {
|
||||
TungsteniteWsMessage::Text(text) => {
|
||||
debug!("Received WebSocket message: {}", text);
|
||||
Self::handle_received_message(&text, pending_requests, &pending_plaintext);
|
||||
}
|
||||
TungsteniteWsMessage::Binary(_) => {
|
||||
debug!("Received binary WebSocket message (Native).");
|
||||
}
|
||||
TungsteniteWsMessage::Ping(_) | TungsteniteWsMessage::Pong(_) => {
|
||||
debug!("Received Ping/Pong (Native).");
|
||||
}
|
||||
TungsteniteWsMessage::Close(_) => {
|
||||
info!("WebSocket connection closed by server (Native).");
|
||||
return "Server closed connection".to_string();
|
||||
}
|
||||
TungsteniteWsMessage::Frame(_) => {
|
||||
debug!("Received Frame (Native) - not typically handled directly.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
error!("WebSocket receive error: {:?}", e);
|
||||
return format!("Receive error: {}", e);
|
||||
}
|
||||
None => {
|
||||
info!("WebSocket connection closed by server (stream ended).");
|
||||
return "Server closed connection (stream ended)".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to handle received messages
|
||||
fn handle_received_message(
|
||||
text: &str,
|
||||
pending_requests: &Arc<Mutex<HashMap<String, oneshot::Sender<Result<JsonRpcResponseClient, CircleWsClientError>>>>>,
|
||||
pending_plaintext: &Arc<Mutex<HashMap<String, oneshot::Sender<Result<String, CircleWsClientError>>>>>,
|
||||
) {
|
||||
// Handle ping/pong messages - these are not JSON-RPC
|
||||
if text.trim() == "pong" {
|
||||
debug!("Received pong response");
|
||||
// Find and respond to any pending plaintext ping requests
|
||||
let mut plaintext_map = pending_plaintext.lock().unwrap();
|
||||
if let Some((_, sender)) = plaintext_map.drain().next() {
|
||||
let _ = sender.send(Ok("pong".to_string()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match serde_json::from_str::<JsonRpcResponseClient>(text) {
|
||||
Ok(response) => {
|
||||
if let Some(sender) = pending_requests.lock().unwrap().remove(&response.id) {
|
||||
if let Err(failed_send_val) = sender.send(Ok(response)) {
|
||||
if let Ok(resp_for_log) = failed_send_val {
|
||||
warn!("Failed to send response to waiting task for ID: {}", resp_for_log.id);
|
||||
} else {
|
||||
warn!("Failed to send response to waiting task, and also failed to get original response for logging.");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("Received response for unknown request ID or unsolicited message: {:?}", response);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to parse JSON-RPC response: {}. Raw: {}", e, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn play(
|
||||
&self,
|
||||
script: String,
|
||||
) -> impl std::future::Future<Output = Result<PlayResultClient, CircleWsClientError>> + Send + 'static
|
||||
{
|
||||
let req_id_outer = Uuid::new_v4().to_string();
|
||||
|
||||
// Clone the sender option. The sender itself (mpsc::Sender) is also Clone.
|
||||
let internal_tx_clone_opt = self.internal_tx.clone();
|
||||
|
||||
async move {
|
||||
let req_id = req_id_outer; // Move req_id into the async block
|
||||
let params = PlayParamsClient { script }; // script is moved in
|
||||
|
||||
let request = match serde_json::to_value(params) {
|
||||
Ok(p_val) => JsonRpcRequestClient {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
method: "play".to_string(),
|
||||
params: p_val,
|
||||
id: req_id.clone(),
|
||||
},
|
||||
Err(e) => return Err(CircleWsClientError::JsonError(e)),
|
||||
};
|
||||
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
|
||||
if let Some(mut internal_tx) = internal_tx_clone_opt {
|
||||
internal_tx
|
||||
.send(InternalWsMessage::SendJsonRpc(request, response_tx))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
CircleWsClientError::ChannelError(format!(
|
||||
"Failed to send request to internal task: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
} else {
|
||||
return Err(CircleWsClientError::NotConnected);
|
||||
}
|
||||
|
||||
// Add a timeout for waiting for the response
|
||||
// For simplicity, using a fixed timeout here. Could be configurable.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
match response_rx.await {
|
||||
Ok(Ok(rpc_response)) => {
|
||||
if let Some(json_rpc_error) = rpc_response.error {
|
||||
Err(CircleWsClientError::JsonRpcError {
|
||||
code: json_rpc_error.code,
|
||||
message: json_rpc_error.message,
|
||||
data: json_rpc_error.data,
|
||||
})
|
||||
} else if let Some(result_value) = rpc_response.result {
|
||||
serde_json::from_value(result_value)
|
||||
.map_err(CircleWsClientError::JsonError)
|
||||
} else {
|
||||
Err(CircleWsClientError::NoResponse(req_id.clone()))
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => Err(e), // Error propagated from the ws task
|
||||
Err(_) => Err(CircleWsClientError::Timeout(req_id.clone())), // oneshot channel cancelled
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use tokio::time::timeout as tokio_timeout;
|
||||
match tokio_timeout(std::time::Duration::from_secs(10), response_rx).await {
|
||||
Ok(Ok(Ok(rpc_response))) => {
|
||||
// Timeout -> Result<ChannelRecvResult, Error>
|
||||
if let Some(json_rpc_error) = rpc_response.error {
|
||||
Err(CircleWsClientError::JsonRpcError {
|
||||
code: json_rpc_error.code,
|
||||
message: json_rpc_error.message,
|
||||
data: json_rpc_error.data,
|
||||
})
|
||||
} else if let Some(result_value) = rpc_response.result {
|
||||
serde_json::from_value(result_value)
|
||||
.map_err(CircleWsClientError::JsonError)
|
||||
} else {
|
||||
Err(CircleWsClientError::NoResponse(req_id.clone()))
|
||||
}
|
||||
}
|
||||
Ok(Ok(Err(e))) => Err(e), // Error propagated from the ws task
|
||||
Ok(Err(_)) => Err(CircleWsClientError::ChannelError(
|
||||
"Response channel cancelled".to_string(),
|
||||
)), // oneshot cancelled
|
||||
Err(_) => Err(CircleWsClientError::Timeout(req_id.clone())), // tokio_timeout expired
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a plaintext ping message and wait for pong response
|
||||
pub async fn ping(&mut self) -> Result<String, CircleWsClientError> {
|
||||
if let Some(mut tx) = self.internal_tx.clone() {
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
|
||||
// Send plaintext ping message
|
||||
tx.send(InternalWsMessage::SendPlaintext("ping".to_string(), response_tx))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
CircleWsClientError::ChannelError(format!(
|
||||
"Failed to send ping request to internal task: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
// Wait for pong response with timeout
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
match response_rx.await {
|
||||
Ok(Ok(response)) => Ok(response),
|
||||
Ok(Err(e)) => Err(e),
|
||||
Err(_) => Err(CircleWsClientError::ChannelError(
|
||||
"Ping response channel cancelled".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use tokio::time::timeout as tokio_timeout;
|
||||
match tokio_timeout(std::time::Duration::from_secs(10), response_rx).await {
|
||||
Ok(Ok(Ok(response))) => Ok(response),
|
||||
Ok(Ok(Err(e))) => Err(e),
|
||||
Ok(Err(_)) => Err(CircleWsClientError::ChannelError(
|
||||
"Ping response channel cancelled".to_string(),
|
||||
)),
|
||||
Err(_) => Err(CircleWsClientError::Timeout("ping".to_string())),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err(CircleWsClientError::NotConnected)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn disconnect(&mut self) {
|
||||
if let Some(mut tx) = self.internal_tx.take() {
|
||||
info!("Sending close signal to internal WebSocket task.");
|
||||
let _ = tx.send(InternalWsMessage::Close).await;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(handle) = self.task_handle.take() {
|
||||
let _ = handle.await; // Wait for the task to finish
|
||||
}
|
||||
info!("Client disconnected.");
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure client cleans up on drop for native targets
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl Drop for CircleWsClient {
|
||||
fn drop(&mut self) {
|
||||
if self.internal_tx.is_some() || self.task_handle.is_some() {
|
||||
warn!("CircleWsClient dropped without explicit disconnect. Spawning task to send close signal.");
|
||||
// We can't call async disconnect directly in drop.
|
||||
// Spawn a new task to send the close message if on native.
|
||||
if let Some(mut tx) = self.internal_tx.take() {
|
||||
spawn_local(async move {
|
||||
info!("Drop: Sending close signal to internal WebSocket task.");
|
||||
let _ = tx.send(InternalWsMessage::Close).await;
|
||||
});
|
||||
}
|
||||
if let Some(handle) = self.task_handle.take() {
|
||||
spawn_local(async move {
|
||||
info!("Drop: Waiting for WebSocket task to finish.");
|
||||
let _ = handle.await;
|
||||
info!("Drop: WebSocket task finished.");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// use super::*;
|
||||
#[test]
|
||||
fn it_compiles() {
|
||||
assert_eq!(2 + 2, 4);
|
||||
}
|
||||
}
|
1
interfaces/websocket/examples/.gitignore
vendored
Normal file
1
interfaces/websocket/examples/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
24
interfaces/websocket/examples/Cargo.toml
Normal file
24
interfaces/websocket/examples/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "hero-websocket-examples"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
hero_websocket_client = { path = "../client" }
|
||||
hero_websocket_server = { path = "../server" }
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
k256 = { version = "0.13", features = ["ecdsa", "sha256"] }
|
||||
rand = "0.8"
|
||||
hex = "0.4"
|
||||
|
||||
[[bin]]
|
||||
name = "ping"
|
||||
path = "src/ping.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "auth"
|
||||
path = "src/auth.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "play"
|
||||
path = "src/play.rs"
|
7
interfaces/websocket/examples/README.md
Normal file
7
interfaces/websocket/examples/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
## Hero Websocket Interface Examples
|
||||
|
||||
A set of end-to-end examples demonstrating the use of the Hero Websocket interface.
|
||||
|
||||
### Ping Example
|
||||
|
||||
Simple ping example.
|
104
interfaces/websocket/examples/src/auth.rs
Normal file
104
interfaces/websocket/examples/src/auth.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use hero_websocket_client::CircleWsClientBuilder;
|
||||
use hero_websocket_server::ServerBuilder;
|
||||
use tokio::signal;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use k256::ecdsa::SigningKey;
|
||||
use k256::elliptic_curve::sec1::ToEncodedPoint;
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (circle_public_key_hex, circle_private_key_hex) = {
|
||||
let signing_key = SigningKey::random(&mut OsRng);
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
let public_key_bytes = verifying_key.to_encoded_point(false).as_bytes().to_vec();
|
||||
let private_key_bytes = signing_key.to_bytes().to_vec();
|
||||
(hex::encode(public_key_bytes), hex::encode(private_key_bytes))
|
||||
};
|
||||
|
||||
println!("🔗 Minimal WebSocket Ping Example");
|
||||
|
||||
// Build server
|
||||
let server = match ServerBuilder::new()
|
||||
.host("127.0.0.1")
|
||||
.port(8443)
|
||||
.redis_url("redis://localhost:6379")
|
||||
.worker_id("test")
|
||||
.with_auth()
|
||||
.build() {
|
||||
Ok(server) => {
|
||||
println!("🚀 Built server...");
|
||||
server
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Failed to build server: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
|
||||
// Start server
|
||||
println!("🚀 Starting server...");
|
||||
let (server_task, server_handle) = server.spawn_circle_server().map_err(|e| {
|
||||
eprintln!("Failed to start server: {}", e);
|
||||
e
|
||||
})?;
|
||||
|
||||
// Setup signal handling for clean shutdown
|
||||
let server_handle_clone = server_handle.clone();
|
||||
tokio::spawn(async move {
|
||||
signal::ctrl_c().await.expect("Failed to listen for Ctrl+C");
|
||||
println!("\n🔌 Shutting down...");
|
||||
server_handle_clone.stop(true).await;
|
||||
std::process::exit(0);
|
||||
});
|
||||
|
||||
// Brief pause for server startup
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Connect client
|
||||
let mut client = CircleWsClientBuilder::new(format!("ws://localhost:8443/{}", circle_public_key_hex))
|
||||
.with_keypair(circle_private_key_hex)
|
||||
.build();
|
||||
|
||||
match client.connect().await {
|
||||
Ok(_) => println!("✅ Client Connected"),
|
||||
Err(e) => {
|
||||
eprintln!("Failed to connect: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate
|
||||
print!("📤 Authenticating... ");
|
||||
let response = client.authenticate().await;
|
||||
match response {
|
||||
Ok(response) => {
|
||||
println!("📥 {}", response);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to authenticate: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Test whoami after authentication
|
||||
print!("📤 Calling whoami... ");
|
||||
match client.whoami().await {
|
||||
Ok(response) => {
|
||||
println!("📥 Whoami response: {}", response);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to call whoami: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Clean shutdown
|
||||
client.disconnect().await;
|
||||
server_handle.stop(true).await;
|
||||
println!("✅ Done");
|
||||
|
||||
Ok(())
|
||||
}
|
3
interfaces/websocket/examples/src/main.rs
Normal file
3
interfaces/websocket/examples/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
}
|
79
interfaces/websocket/examples/src/ping.rs
Normal file
79
interfaces/websocket/examples/src/ping.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use hero_websocket_client::CircleWsClientBuilder;
|
||||
use hero_websocket_server::ServerBuilder;
|
||||
use std::time::Instant;
|
||||
use tokio::signal;
|
||||
use tokio::time::{sleep, timeout, Duration};
|
||||
|
||||
const CIRCLE_PUBLIC_KEY: &str = "circle_public_key";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("🔗 Minimal WebSocket Ping Example");
|
||||
|
||||
// Build server
|
||||
let server = match ServerBuilder::new()
|
||||
.host("127.0.0.1")
|
||||
.port(8443)
|
||||
.redis_url("redis://localhost:6379")
|
||||
.worker_id("test")
|
||||
.build() {
|
||||
Ok(server) => {
|
||||
println!("🚀 Built server...");
|
||||
server
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Failed to build server: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
|
||||
// Start server
|
||||
println!("🚀 Starting server...");
|
||||
let (server_task, server_handle) = server.spawn_circle_server().map_err(|e| {
|
||||
eprintln!("Failed to start server: {}", e);
|
||||
e
|
||||
})?;
|
||||
|
||||
// Setup signal handling for clean shutdown
|
||||
let server_handle_clone = server_handle.clone();
|
||||
tokio::spawn(async move {
|
||||
signal::ctrl_c().await.expect("Failed to listen for Ctrl+C");
|
||||
println!("\n🔌 Shutting down...");
|
||||
server_handle_clone.stop(true).await;
|
||||
std::process::exit(0);
|
||||
});
|
||||
|
||||
// Brief pause for server startup
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Connect client
|
||||
let mut client = CircleWsClientBuilder::new(format!("ws://localhost:8443/{}", CIRCLE_PUBLIC_KEY)).build();
|
||||
|
||||
match client.connect().await {
|
||||
Ok(_) => println!("✅ Client Connected"),
|
||||
Err(e) => {
|
||||
eprintln!("Failed to connect: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Send one ping
|
||||
print!("📤 Ping... ");
|
||||
let response = client.ping().await;
|
||||
match response {
|
||||
Ok(response) => {
|
||||
println!("📥 {}", response);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to ping: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Clean shutdown
|
||||
client.disconnect().await;
|
||||
server_handle.stop(true).await;
|
||||
println!("✅ Done");
|
||||
|
||||
Ok(())
|
||||
}
|
96
interfaces/websocket/examples/src/play.rs
Normal file
96
interfaces/websocket/examples/src/play.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use hero_websocket_client::CircleWsClientBuilder;
|
||||
use hero_websocket_server::ServerBuilder;
|
||||
use tokio::signal;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use k256::ecdsa::SigningKey;
|
||||
use k256::elliptic_curve::sec1::ToEncodedPoint;
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (circle_public_key_hex, circle_private_key_hex) = {
|
||||
let signing_key = SigningKey::random(&mut OsRng);
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
let public_key_bytes = verifying_key.to_encoded_point(false).as_bytes().to_vec();
|
||||
let private_key_bytes = signing_key.to_bytes().to_vec();
|
||||
(hex::encode(public_key_bytes), hex::encode(private_key_bytes))
|
||||
};
|
||||
|
||||
println!("🔗 Minimal WebSocket Ping Example");
|
||||
|
||||
// Build server
|
||||
let server = match ServerBuilder::new()
|
||||
.host("127.0.0.1")
|
||||
.port(8443)
|
||||
.redis_url("redis://localhost:6379")
|
||||
.worker_id("test")
|
||||
.with_auth()
|
||||
.build() {
|
||||
Ok(server) => {
|
||||
println!("🚀 Built server...");
|
||||
server
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Failed to build server: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
|
||||
// Start server
|
||||
println!("🚀 Starting server...");
|
||||
let (server_task, server_handle) = server.spawn_circle_server().map_err(|e| {
|
||||
eprintln!("Failed to start server: {}", e);
|
||||
e
|
||||
})?;
|
||||
|
||||
// Setup signal handling for clean shutdown
|
||||
let server_handle_clone = server_handle.clone();
|
||||
tokio::spawn(async move {
|
||||
signal::ctrl_c().await.expect("Failed to listen for Ctrl+C");
|
||||
println!("\n🔌 Shutting down...");
|
||||
server_handle_clone.stop(true).await;
|
||||
std::process::exit(0);
|
||||
});
|
||||
|
||||
// Brief pause for server startup
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Connect client
|
||||
let mut client = CircleWsClientBuilder::new(format!("ws://localhost:8443/{}", circle_public_key_hex))
|
||||
.with_keypair(circle_private_key_hex)
|
||||
.build();
|
||||
|
||||
match client.connect().await {
|
||||
Ok(_) => println!("✅ Client Connected"),
|
||||
Err(e) => {
|
||||
eprintln!("Failed to connect: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate
|
||||
print!("📤 Authenticating... ");
|
||||
let response = client.authenticate().await;
|
||||
match response {
|
||||
Ok(response) => {
|
||||
println!("📥 {}", response);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to authenticate: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Test whoami after authentication
|
||||
print!("📤 Calling whoami... ");
|
||||
client.play("script".to_string()).await;
|
||||
|
||||
// Clean shutdown
|
||||
client.disconnect().await;
|
||||
server_handle.stop(true).await;
|
||||
println!("✅ Done");
|
||||
|
||||
Ok(())
|
||||
}
|
BIN
interfaces/websocket/server/.DS_Store
vendored
Normal file
BIN
interfaces/websocket/server/.DS_Store
vendored
Normal file
Binary file not shown.
10
interfaces/websocket/server/.env.example
Normal file
10
interfaces/websocket/server/.env.example
Normal 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
|
3
interfaces/websocket/server/.gitignore
vendored
Normal file
3
interfaces/websocket/server/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
file:memdb_test_server*
|
||||
*.pem
|
2775
interfaces/websocket/server/Cargo.lock
generated
Normal file
2775
interfaces/websocket/server/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
78
interfaces/websocket/server/Cargo.toml
Normal file
78
interfaces/websocket/server/Cargo.toml
Normal 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"
|
76
interfaces/websocket/server/README.md
Normal file
76
interfaces/websocket/server/README.md
Normal 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
|
||||
```
|
142
interfaces/websocket/server/cmd/README.md
Normal file
142
interfaces/websocket/server/cmd/README.md
Normal 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
|
150
interfaces/websocket/server/cmd/main.rs
Normal file
150
interfaces/websocket/server/cmd/main.rs
Normal 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?
|
||||
}
|
133
interfaces/websocket/server/docs/ARCHITECTURE.md
Normal file
133
interfaces/websocket/server/docs/ARCHITECTURE.md
Normal 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) |
|
214
interfaces/websocket/server/docs/authentication.md
Normal file
214
interfaces/websocket/server/docs/authentication.md
Normal 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
|
357
interfaces/websocket/server/docs/webhooks.md
Normal file
357
interfaces/websocket/server/docs/webhooks.md
Normal 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
|
62
interfaces/websocket/server/openrpc.json
Normal file
62
interfaces/websocket/server/openrpc.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
110
interfaces/websocket/server/src/auth.rs
Normal file
110
interfaces/websocket/server/src/auth.rs
Normal 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);
|
||||
}
|
||||
}
|
100
interfaces/websocket/server/src/builder.rs
Normal file
100
interfaces/websocket/server/src/builder.rs
Normal 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()
|
||||
}
|
||||
}
|
90
interfaces/websocket/server/src/handler.rs
Normal file
90
interfaces/websocket/server/src/handler.rs
Normal 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();
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
637
interfaces/websocket/server/src/lib.rs
Normal file
637
interfaces/websocket/server/src/lib.rs
Normal 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,
|
||||
)
|
||||
}
|
||||
|
76
interfaces/websocket/server/tests/basic_integration_test.rs
Normal file
76
interfaces/websocket/server/tests/basic_integration_test.rs
Normal 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;
|
||||
}
|
25
interfaces/websocket/server/tests/connection_test.rs
Normal file
25
interfaces/websocket/server/tests/connection_test.rs
Normal 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();
|
||||
}
|
119
interfaces/websocket/server/tests/timeout_integration_test.rs
Normal file
119
interfaces/websocket/server/tests/timeout_integration_test.rs
Normal 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();
|
||||
}
|
85
interfaces/websocket/server/tests/wss_integration_test.rs
Normal file
85
interfaces/websocket/server/tests/wss_integration_test.rs
Normal 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()));
|
||||
}
|
Reference in New Issue
Block a user