This commit is contained in:
Timur Gordon
2025-08-01 00:01:08 +02:00
parent 32c2cbe0cc
commit 8ed40ce99c
57 changed files with 2047 additions and 4113 deletions

View File

@@ -595,7 +595,7 @@ dependencies = [
"log",
"once_cell",
"redis",
"rhai_dispatcher",
"rhai_supervisor",
"rustls",
"rustls-pemfile",
"serde",
@@ -1765,7 +1765,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "rhai_dispatcher"
name = "rhai_supervisor"
version = "0.1.0"
dependencies = [
"chrono",

View File

@@ -584,7 +584,7 @@ dependencies = [
"once_cell",
"rand 0.8.5",
"redis",
"rhai_dispatcher",
"rhai_supervisor",
"rustls",
"rustls-pemfile",
"secp256k1",
@@ -1769,7 +1769,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "rhai_dispatcher"
name = "rhai_supervisor"
version = "0.1.0"
dependencies = [
"chrono",

View File

@@ -44,7 +44,7 @@ redis = { workspace = true }
uuid = { workspace = true }
tokio = { workspace = true }
chrono = { workspace = true }
hero_dispatcher = { path = "../../../core/dispatcher" }
hero_supervisor = { path = "../../../core/supervisor" }
hero_job = { path = "../../../core/job" }
thiserror = { workspace = true }
heromodels = { path = "../../../../db/heromodels" }

View File

@@ -4,9 +4,14 @@ An OpenRPC WebSocket Server to interface with the [cores](../../core) of authori
- [OpenRPC Specification](openrpc.json) defines the API.
- There are RPC Operations specified to authorize a websocket connection.
- Authorized clients can execute Rhai scripts on the server.
- Authorized clients can manage jobs.
- The server uses the [supervisor] to dispatch [jobs] to the [workers].
## Circles
Circles are contexts which a hero can act in. Each circle has a unique public key and a set of members.
The server offers a separate path for each circle.
## 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:

View File

@@ -1,7 +1,5 @@
use hero_websocket_server::{ServerBuilder, TlsConfigError};
use hero_websocket_server::{ServerBuilder, ServerConfig};
use clap::Parser;
use dotenv::dotenv;
use log::info;
#[derive(Parser, Debug)]
#[clap(author, version, about, long_about = None)]
@@ -39,14 +37,62 @@ struct Args {
#[clap(long, help = "Enable webhook handling")]
webhooks: bool,
#[clap(long, value_parser, help = "Worker ID for the server")]
worker_id: String,
#[clap(short, long, value_parser, help = "Path to configuration file")]
config: Option<String>,
#[clap(long, help = "Generate a sample configuration file")]
generate_config: bool,
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let args = Args::parse();
// Handle config file generation
if args.generate_config {
let sample_config = ServerConfig::create_sample();
let config_path = "config.json";
match sample_config.to_file(config_path) {
Ok(_) => {
println!("✅ Sample configuration file generated: {}", config_path);
println!("📝 Edit the file to customize your server configuration.");
return Ok(());
}
Err(e) => {
eprintln!("❌ Failed to generate config file: {}", e);
std::process::exit(1);
}
}
}
// Load configuration from file if provided, otherwise use CLI args
let config = if let Some(config_path) = &args.config {
match ServerConfig::from_file(config_path) {
Ok(config) => {
println!("📄 Loaded configuration from: {}", config_path);
config
}
Err(e) => {
eprintln!("❌ Failed to load config file {}: {}", config_path, e);
std::process::exit(1);
}
}
} else {
// Create config from CLI arguments
ServerConfig {
host: args.host.clone(),
port: args.port,
redis_url: args.redis_url.clone(),
auth: args.auth,
tls: args.tls,
cert: args.cert.clone(),
key: args.key.clone(),
tls_port: args.tls_port,
webhooks: args.webhooks,
circles: std::collections::HashMap::new(), // Empty circles when using CLI
}
};
// Configure logging based on verbosity level
let log_config = match args.verbose {
0 => {
@@ -78,39 +124,14 @@ async fn main() -> std::io::Result<()> {
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");
// Validate configuration
if let Err(e) = config.validate() {
eprintln!("❌ Configuration validation failed: {}", e);
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();
}
// Build server from configuration
let builder = ServerBuilder::new().from_config(config.clone());
let server = match builder.build() {
Ok(server) => server,
@@ -122,27 +143,36 @@ async fn main() -> std::io::Result<()> {
println!("🚀 Starting Circles WebSocket Server");
println!("📋 Configuration:");
println!(" Host: {}", args.host);
println!(" Port: {}", args.port);
if let Some(tls_port) = args.tls_port {
println!(" Host: {}", config.host);
println!(" Port: {}", config.port);
println!(" Redis URL: {}", config.redis_url);
if let Some(tls_port) = config.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" });
println!(" Authentication: {}", if config.auth { "ENABLED" } else { "DISABLED" });
println!(" TLS/WSS: {}", if config.tls { "ENABLED" } else { "DISABLED" });
println!(" Webhooks: {}", if config.webhooks { "ENABLED" } else { "DISABLED" });
println!(" Circles configured: {}", config.circles.len());
if args.tls {
if let (Some(cert), Some(key)) = (&args.cert, &args.key) {
if config.tls {
if let (Some(cert), Some(key)) = (&config.cert, &config.key) {
println!(" Certificate: {}", cert);
println!(" Private Key: {}", key);
}
}
if args.webhooks {
if config.webhooks {
println!(" Webhook secrets loaded from environment variables:");
println!(" - STRIPE_WEBHOOK_SECRET");
println!(" - IDENFY_WEBHOOK_SECRET");
}
if config.auth && !config.circles.is_empty() {
println!(" Configured circles:");
for (circle_name, members) in &config.circles {
println!(" - {}: {} members", circle_name, members.len());
}
}
println!();
let (server_task, _server_handle) = server.spawn_circle_server()?;

View File

@@ -90,7 +90,7 @@ sequenceDiagram
participant HS as HttpServer
participant WH as Webhook Handler
participant WV as Webhook Verifier
participant RC as RhaiDispatcher
participant RC as RhaiSupervisor
participant Redis as Redis
WS->>+HS: POST /webhooks/{provider}/{circle_pk}
@@ -102,7 +102,7 @@ sequenceDiagram
alt Signature Valid
WH->>WH: Parse webhook payload (heromodels types)
WH->>+RC: Create RhaiDispatcher with caller_id
WH->>+RC: Create RhaiSupervisor with caller_id
RC->>+Redis: Execute webhook script
Redis-->>-RC: Script result
RC-->>-WH: Execution result
@@ -128,6 +128,6 @@ sequenceDiagram
| **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 |
| **Script Execution** | Direct via authenticated session | Via RhaiSupervisor with provider caller_id |
| **Use Case** | Interactive client applications | External service notifications |
| **Data Types** | JSON-RPC messages | Provider-specific webhook payloads (heromodels) |

View File

@@ -19,8 +19,8 @@ graph TB
E[Webhook Handler]
F[Stripe Verifier]
G[iDenfy Verifier]
H[Script Dispatcher]
I[RhaiDispatcherBuilder]
H[Script Supervisor]
I[RhaiSupervisorBuilder]
end
subgraph "Configuration"
@@ -92,8 +92,8 @@ 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 SD as Script Supervisor
participant RC as RhaiSupervisor
participant RW as Rhai Worker
WS->>CS: POST /webhooks/stripe/{circle_pk}
@@ -113,7 +113,7 @@ sequenceDiagram
alt Verification Success
CS->>SD: Dispatch appropriate script
SD->>RC: Create RhaiDispatcherBuilder
SD->>RC: Create RhaiSupervisorBuilder
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"
@@ -248,8 +248,8 @@ heromodels/src/models/
### 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
- **Simplified Architecture**: Removed unnecessary supervisor complexity
- **Direct Script Execution**: Handlers directly use `RhaiSupervisor` for script execution
### Modified Files
- `src/lib.rs` - Add webhook routes and module imports

View File

@@ -1,5 +1,5 @@
use std::collections::HashMap;
use crate::{Server, TlsConfigError};
use crate::{Server, TlsConfigError, ServerConfig};
/// ServerBuilder for constructing Server instances with a fluent API
pub struct ServerBuilder {
@@ -12,7 +12,7 @@ pub struct ServerBuilder {
tls_port: Option<u16>,
enable_auth: bool,
enable_webhooks: bool,
circle_worker_id: String,
circles: HashMap<String, Vec<String>>,
}
@@ -28,7 +28,7 @@ impl ServerBuilder {
tls_port: None,
enable_auth: false,
enable_webhooks: false,
circle_worker_id: "default".to_string(),
circles: HashMap::new(),
}
}
@@ -48,10 +48,7 @@ impl ServerBuilder {
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;
@@ -79,6 +76,21 @@ impl ServerBuilder {
self.circles = circles;
self
}
/// Load configuration from a ServerConfig instance
pub fn from_config(mut self, config: ServerConfig) -> Self {
self.host = config.host;
self.port = config.port;
self.redis_url = config.redis_url;
self.enable_auth = config.auth;
self.enable_tls = config.tls;
self.cert_path = config.cert;
self.key_path = config.key;
self.tls_port = config.tls_port;
self.enable_webhooks = config.webhooks;
self.circles = config.circles;
self
}
pub fn build(self) -> Result<Server, TlsConfigError> {
Ok(Server {
@@ -91,13 +103,13 @@ impl ServerBuilder {
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(),
circles: self.circles,
nonce_store: HashMap::new(),
authenticated_pubkey: None,
dispatcher: None,
supervisor: None,
})
}
}

View File

@@ -1,7 +1,7 @@
use crate::Server;
use actix::prelude::*;
use actix_web_actors::ws;
use hero_dispatcher::{Dispatcher, ScriptType};
use hero_supervisor::{Supervisor, ScriptType};
use serde_json::{json, Value};
use std::time::Duration;
@@ -82,7 +82,7 @@ impl Server {
}
};
let dispatcher = match self.dispatcher.clone() {
let supervisor = match self.supervisor.clone() {
Some(d) => d,
None => {
let err_resp = JsonRpcResponse {
@@ -90,7 +90,7 @@ impl Server {
result: None,
error: Some(JsonRpcError {
code: -32603,
message: "Internal error: dispatcher not available".to_string(),
message: "Internal error: supervisor not available".to_string(),
data: None,
}),
id: client_rpc_id,
@@ -102,7 +102,7 @@ impl Server {
let client_rpc_id_clone = client_rpc_id.clone();
let fut = async move {
dispatcher.start_job(&job_id).await
supervisor.start_job(&job_id).await
};
ctx.spawn(
@@ -190,7 +190,7 @@ impl Server {
}
};
let dispatcher = match self.dispatcher.clone() {
let supervisor = match self.supervisor.clone() {
Some(d) => d,
None => {
let err_resp = JsonRpcResponse {
@@ -198,7 +198,7 @@ impl Server {
result: None,
error: Some(JsonRpcError {
code: -32603,
message: "Internal error: dispatcher not available".to_string(),
message: "Internal error: supervisor not available".to_string(),
data: None,
}),
id: client_rpc_id,
@@ -210,7 +210,7 @@ impl Server {
let client_rpc_id_clone = client_rpc_id.clone();
let fut = async move {
dispatcher.get_job_status(&job_id).await
supervisor.get_job_status(&job_id).await
};
ctx.spawn(
@@ -279,7 +279,7 @@ impl Server {
return;
}
let dispatcher = match self.dispatcher.clone() {
let supervisor = match self.supervisor.clone() {
Some(d) => d,
None => {
let err_resp = JsonRpcResponse {
@@ -287,7 +287,7 @@ impl Server {
result: None,
error: Some(JsonRpcError {
code: -32603,
message: "Internal error: dispatcher not available".to_string(),
message: "Internal error: supervisor not available".to_string(),
data: None,
}),
id: client_rpc_id,
@@ -299,7 +299,7 @@ impl Server {
let client_rpc_id_clone = client_rpc_id.clone();
let fut = async move {
dispatcher.list_jobs().await
supervisor.list_jobs().await
};
ctx.spawn(
@@ -403,7 +403,7 @@ impl Server {
}
};
let dispatcher = match self.dispatcher.clone() {
let supervisor = match self.supervisor.clone() {
Some(d) => d,
None => {
let err_resp = JsonRpcResponse {
@@ -411,7 +411,7 @@ impl Server {
result: None,
error: Some(JsonRpcError {
code: -32603,
message: "Internal error: dispatcher not available".to_string(),
message: "Internal error: supervisor not available".to_string(),
data: None,
}),
id: client_rpc_id,
@@ -423,7 +423,7 @@ impl Server {
let client_rpc_id_clone = client_rpc_id.clone();
let fut = async move {
dispatcher
supervisor
.new_job()
.context_id(&circle_pk)
.script_type(ScriptType::RhaiSAL)
@@ -518,7 +518,7 @@ impl Server {
}
};
let dispatcher = match self.dispatcher.clone() {
let supervisor = match self.supervisor.clone() {
Some(d) => d,
None => {
let err_resp = JsonRpcResponse {
@@ -526,7 +526,7 @@ impl Server {
result: None,
error: Some(JsonRpcError {
code: -32603,
message: "Internal error: dispatcher not available".to_string(),
message: "Internal error: supervisor not available".to_string(),
data: None,
}),
id: client_rpc_id,
@@ -538,7 +538,7 @@ impl Server {
let client_rpc_id_clone = client_rpc_id.clone();
let fut = async move {
dispatcher.get_job_output(&job_id).await
supervisor.get_job_output(&job_id).await
};
ctx.spawn(
@@ -625,7 +625,7 @@ impl Server {
}
};
let dispatcher = match self.dispatcher.clone() {
let supervisor = match self.supervisor.clone() {
Some(d) => d,
None => {
let err_resp = JsonRpcResponse {
@@ -633,7 +633,7 @@ impl Server {
result: None,
error: Some(JsonRpcError {
code: -32603,
message: "Internal error: dispatcher not available".to_string(),
message: "Internal error: supervisor not available".to_string(),
data: None,
}),
id: client_rpc_id,
@@ -645,7 +645,7 @@ impl Server {
let client_rpc_id_clone = client_rpc_id.clone();
let fut = async move {
dispatcher.get_job_logs(&job_id).await
supervisor.get_job_logs(&job_id).await
};
ctx.spawn(
@@ -733,7 +733,7 @@ impl Server {
}
};
let dispatcher = match self.dispatcher.clone() {
let supervisor = match self.supervisor.clone() {
Some(d) => d,
None => {
let err_resp = JsonRpcResponse {
@@ -741,7 +741,7 @@ impl Server {
result: None,
error: Some(JsonRpcError {
code: -32603,
message: "Internal error: dispatcher not available".to_string(),
message: "Internal error: supervisor not available".to_string(),
data: None,
}),
id: client_rpc_id,
@@ -753,7 +753,7 @@ impl Server {
let client_rpc_id_clone = client_rpc_id.clone();
let fut = async move {
dispatcher.stop_job(&job_id).await
supervisor.stop_job(&job_id).await
};
ctx.spawn(
@@ -840,7 +840,7 @@ impl Server {
}
};
let dispatcher = match self.dispatcher.clone() {
let supervisor = match self.supervisor.clone() {
Some(d) => d,
None => {
let err_resp = JsonRpcResponse {
@@ -848,7 +848,7 @@ impl Server {
result: None,
error: Some(JsonRpcError {
code: -32603,
message: "Internal error: dispatcher not available".to_string(),
message: "Internal error: supervisor not available".to_string(),
data: None,
}),
id: client_rpc_id,
@@ -860,7 +860,7 @@ impl Server {
let client_rpc_id_clone = client_rpc_id.clone();
let fut = async move {
dispatcher.delete_job(&job_id).await
supervisor.delete_job(&job_id).await
};
ctx.spawn(
@@ -929,7 +929,7 @@ impl Server {
return;
}
let dispatcher = match self.dispatcher.clone() {
let supervisor = match self.supervisor.clone() {
Some(d) => d,
None => {
let err_resp = JsonRpcResponse {
@@ -937,7 +937,7 @@ impl Server {
result: None,
error: Some(JsonRpcError {
code: -32603,
message: "Internal error: dispatcher not available".to_string(),
message: "Internal error: supervisor not available".to_string(),
data: None,
}),
id: client_rpc_id,
@@ -949,7 +949,7 @@ impl Server {
let client_rpc_id_clone = client_rpc_id.clone();
let fut = async move {
dispatcher.clear_all_jobs().await
supervisor.clear_all_jobs().await
};
ctx.spawn(

View File

@@ -3,7 +3,7 @@ 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::{Dispatcher, DispatcherBuilder, DispatcherError};
use hero_supervisor::{Supervisor, SupervisorBuilder, SupervisorError};
use hero_job::{Job, JobStatus};
use rustls::pki_types::PrivateKeyDer;
use rustls::ServerConfig as RustlsServerConfig;
@@ -211,7 +211,7 @@ pub struct Server {
pub circles: HashMap<String, Vec<String>>,
nonce_store: HashMap<String, NonceResponse>,
authenticated_pubkey: Option<String>,
pub dispatcher: Option<Dispatcher>,
pub supervisor: Option<Supervisor>,
}
impl Server {
@@ -552,15 +552,15 @@ impl Server {
let fut = async move {
let caller_id = public_key.unwrap_or_else(|| "anonymous".to_string());
match DispatcherBuilder::new()
match SupervisorBuilder::new()
.redis_url(&redis_url_clone)
.caller_id(&caller_id)
.build() {
Ok(hero_dispatcher) => {
hero_dispatcher
Ok(hero_supervisor) => {
hero_supervisor
.new_job()
.context_id(&circle_pk_clone)
.script_type(hero_dispatcher::ScriptType::RhaiSAL)
.script_type(hero_supervisor::ScriptType::RhaiSAL)
.script(&script_content)
.timeout(TASK_TIMEOUT_DURATION)
.await_response()
@@ -574,7 +574,7 @@ impl Server {
fut.into_actor(self)
.map(move |res, _act, ctx_inner| match res {
Ok(output) => {
// The dispatcher returns the actual string output from job execution
// The supervisor returns the actual string output from job execution
let result_value = PlayResult { output };
let resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
@@ -586,7 +586,7 @@ impl Server {
}
Err(e) => {
let (code, message) = match e {
DispatcherError::Timeout(task_id) => (
SupervisorError::Timeout(task_id) => (
-32002,
format!(
"Timeout waiting for Rhai script (task: {})",