end to end job management support

This commit is contained in:
Timur Gordon
2025-07-30 08:36:55 +02:00
parent 7d7ff0f0ab
commit 32c2cbe0cc
20 changed files with 2686 additions and 442 deletions

View File

@@ -13,6 +13,7 @@ pub struct ServerBuilder {
enable_auth: bool,
enable_webhooks: bool,
circle_worker_id: String,
circles: HashMap<String, Vec<String>>,
}
impl ServerBuilder {
@@ -28,6 +29,7 @@ impl ServerBuilder {
enable_auth: false,
enable_webhooks: false,
circle_worker_id: "default".to_string(),
circles: HashMap::new(),
}
}
@@ -72,6 +74,11 @@ impl ServerBuilder {
self.enable_webhooks = true;
self
}
pub fn circles(mut self, circles: HashMap<String, Vec<String>>) -> Self {
self.circles = circles;
self
}
pub fn build(self) -> Result<Server, TlsConfigError> {
Ok(Server {
@@ -87,8 +94,10 @@ impl ServerBuilder {
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,
})
}
}

View File

@@ -0,0 +1,220 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
/// Server host address
#[serde(default = "default_host")]
pub host: String,
/// Server port
#[serde(default = "default_port")]
pub port: u16,
/// Redis connection URL
#[serde(default = "default_redis_url")]
pub redis_url: String,
/// Enable authentication
#[serde(default)]
pub auth: bool,
/// Enable TLS/WSS
#[serde(default)]
pub tls: bool,
/// Path to TLS certificate file
pub cert: Option<String>,
/// Path to TLS private key file
pub key: Option<String>,
/// Separate port for TLS connections
pub tls_port: Option<u16>,
/// Enable webhook handling
#[serde(default)]
pub webhooks: bool,
/// Circles configuration - maps circle names to lists of member public keys
#[serde(default)]
pub circles: HashMap<String, Vec<String>>,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: default_host(),
port: default_port(),
redis_url: default_redis_url(),
auth: false,
tls: false,
cert: None,
key: None,
tls_port: None,
webhooks: false,
circles: HashMap::new(),
}
}
}
impl ServerConfig {
/// Load configuration from a JSON file
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
let content = fs::read_to_string(path.as_ref())
.map_err(|e| ConfigError::FileRead(path.as_ref().to_path_buf(), e))?;
let config: ServerConfig = serde_json::from_str(&content)
.map_err(|e| ConfigError::JsonParse(e))?;
config.validate()?;
Ok(config)
}
/// Save configuration to a JSON file
pub fn to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), ConfigError> {
let content = serde_json::to_string_pretty(self)
.map_err(|e| ConfigError::JsonSerialize(e))?;
fs::write(path.as_ref(), content)
.map_err(|e| ConfigError::FileWrite(path.as_ref().to_path_buf(), e))?;
Ok(())
}
/// Validate the configuration
pub fn validate(&self) -> Result<(), ConfigError> {
// Validate TLS configuration
if self.tls && (self.cert.is_none() || self.key.is_none()) {
return Err(ConfigError::InvalidTlsConfig(
"TLS is enabled but certificate or key path is missing".to_string()
));
}
// Validate that circles are not empty if auth is enabled
if self.auth && self.circles.is_empty() {
return Err(ConfigError::InvalidAuthConfig(
"Authentication is enabled but no circles are configured".to_string()
));
}
Ok(())
}
/// Create a sample configuration file
pub fn create_sample() -> Self {
let mut circles = HashMap::new();
circles.insert(
"example_circle".to_string(),
vec![
"0x1234567890abcdef1234567890abcdef12345678".to_string(),
"0xabcdef1234567890abcdef1234567890abcdef12".to_string(),
]
);
Self {
host: "127.0.0.1".to_string(),
port: 8443,
redis_url: "redis://127.0.0.1/".to_string(),
auth: true,
tls: false,
cert: Some("cert.pem".to_string()),
key: Some("key.pem".to_string()),
tls_port: Some(8444),
webhooks: false,
circles,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("Failed to read config file {0}: {1}")]
FileRead(std::path::PathBuf, std::io::Error),
#[error("Failed to write config file {0}: {1}")]
FileWrite(std::path::PathBuf, std::io::Error),
#[error("Failed to parse JSON config: {0}")]
JsonParse(serde_json::Error),
#[error("Failed to serialize JSON config: {0}")]
JsonSerialize(serde_json::Error),
#[error("Invalid TLS configuration: {0}")]
InvalidTlsConfig(String),
#[error("Invalid authentication configuration: {0}")]
InvalidAuthConfig(String),
}
// Default value functions
fn default_host() -> String {
"127.0.0.1".to_string()
}
fn default_port() -> u16 {
8443
}
fn default_redis_url() -> String {
"redis://127.0.0.1/".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn test_config_serialization() {
let config = ServerConfig::create_sample();
let json = serde_json::to_string_pretty(&config).unwrap();
let deserialized: ServerConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config.host, deserialized.host);
assert_eq!(config.port, deserialized.port);
assert_eq!(config.circles.len(), deserialized.circles.len());
}
#[test]
fn test_config_file_operations() {
let config = ServerConfig::create_sample();
let temp_file = NamedTempFile::new().unwrap();
// Test writing
config.to_file(temp_file.path()).unwrap();
// Test reading
let loaded_config = ServerConfig::from_file(temp_file.path()).unwrap();
assert_eq!(config.host, loaded_config.host);
assert_eq!(config.circles.len(), loaded_config.circles.len());
}
#[test]
fn test_config_validation() {
let mut config = ServerConfig::default();
// Valid config should pass
assert!(config.validate().is_ok());
// TLS enabled without cert/key should fail
config.tls = true;
assert!(config.validate().is_err());
// Fix TLS config
config.cert = Some("cert.pem".to_string());
config.key = Some("key.pem".to_string());
assert!(config.validate().is_ok());
// Auth enabled without circles should fail
config.auth = true;
assert!(config.validate().is_err());
// Add circles
config.circles.insert("test".to_string(), vec!["pubkey".to_string()]);
assert!(config.validate().is_ok());
}
}

View File

@@ -31,6 +31,16 @@ impl actix::StreamHandler<Result<ws::Message, ws::ProtocolError>> for Server {
self.handle_whoami(req.params, client_rpc_id, ctx)
}
"play" => self.handle_play(req.params, client_rpc_id, ctx),
"create_job" => self.handle_create_job(req.params, client_rpc_id, ctx),
"start_job" => self.handle_start_job(req.params, client_rpc_id, ctx),
"run_job" => self.handle_run_job(req.params, client_rpc_id, ctx),
"get_job_status" => self.handle_get_job_status(req.params, client_rpc_id, ctx),
"get_job_output" => self.handle_get_job_output(req.params, client_rpc_id, ctx),
"get_job_logs" => self.handle_get_job_logs(req.params, client_rpc_id, ctx),
"list_jobs" => self.handle_list_jobs(req.params, client_rpc_id, ctx),
"stop_job" => self.handle_stop_job(req.params, client_rpc_id, ctx),
"delete_job" => self.handle_delete_job(req.params, client_rpc_id, ctx),
"clear_all_jobs" => self.handle_clear_all_jobs(req.params, client_rpc_id, ctx),
_ => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),

View File

@@ -0,0 +1,999 @@
use crate::Server;
use actix::prelude::*;
use actix_web_actors::ws;
use hero_dispatcher::{Dispatcher, ScriptType};
use serde_json::{json, Value};
use std::time::Duration;
const TASK_TIMEOUT_DURATION: Duration = Duration::from_secs(30);
#[derive(serde::Serialize)]
struct SuccessResult {
success: bool,
}
#[derive(serde::Serialize)]
struct JobResult {
job_id: String,
}
#[derive(serde::Serialize)]
struct JsonRpcResponse {
jsonrpc: String,
result: Option<Value>,
error: Option<JsonRpcError>,
id: Value,
}
#[derive(serde::Serialize)]
struct JsonRpcError {
code: i32,
message: String,
data: Option<Value>,
}
impl Server {
pub fn handle_create_job(
&mut self,
params: Value,
client_rpc_id: Value,
ctx: &mut ws::WebsocketContext<Self>,
) {
// For now, create_job is the same as run_job
self.handle_run_job(params, client_rpc_id, ctx);
}
pub fn handle_start_job(
&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: -32000,
message: "Authentication required".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
let job_id = match params.get("job_id").and_then(|v| v.as_str()) {
Some(id) => id.to_string(),
None => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32602,
message: "Missing required parameter: job_id".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
};
let dispatcher = match self.dispatcher.clone() {
Some(d) => d,
None => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32603,
message: "Internal error: dispatcher not available".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
};
let client_rpc_id_clone = client_rpc_id.clone();
let fut = async move {
dispatcher.start_job(&job_id).await
};
ctx.spawn(
fut.into_actor(self)
.map(move |res, _act, ctx_inner| match res {
Ok(_) => {
let result = SuccessResult { success: true };
let resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: Some(serde_json::to_value(result).unwrap()),
error: None,
id: client_rpc_id_clone.clone(),
};
ctx_inner.text(serde_json::to_string(&resp).unwrap());
}
Err(e) => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32000,
message: format!("Failed to start job: {}", e),
data: None,
}),
id: client_rpc_id_clone,
};
ctx_inner.text(serde_json::to_string(&err_resp).unwrap());
}
})
.timeout(TASK_TIMEOUT_DURATION)
.map(move |res, _act, ctx_inner| {
if res.is_err() {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32000,
message: "Request timed out".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx_inner.text(serde_json::to_string(&err_resp).unwrap());
}
}),
);
}
pub fn handle_get_job_status(
&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: -32000,
message: "Authentication required".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
let job_id = match params.get("job_id").and_then(|v| v.as_str()) {
Some(id) => id.to_string(),
None => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32602,
message: "Missing required parameter: job_id".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
};
let dispatcher = match self.dispatcher.clone() {
Some(d) => d,
None => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32603,
message: "Internal error: dispatcher not available".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
};
let client_rpc_id_clone = client_rpc_id.clone();
let fut = async move {
dispatcher.get_job_status(&job_id).await
};
ctx.spawn(
fut.into_actor(self)
.map(move |res, _act, ctx_inner| match res {
Ok(status) => {
let resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: Some(json!(status)),
error: None,
id: client_rpc_id_clone.clone(),
};
ctx_inner.text(serde_json::to_string(&resp).unwrap());
}
Err(e) => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32000,
message: format!("Failed to get job status: {}", e),
data: None,
}),
id: client_rpc_id_clone,
};
ctx_inner.text(serde_json::to_string(&err_resp).unwrap());
}
})
.timeout(TASK_TIMEOUT_DURATION)
.map(move |res, _act, ctx_inner| {
if res.is_err() {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32000,
message: "Request timed out".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx_inner.text(serde_json::to_string(&err_resp).unwrap());
}
}),
);
}
pub fn handle_list_jobs(
&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: -32000,
message: "Authentication required".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
let dispatcher = match self.dispatcher.clone() {
Some(d) => d,
None => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32603,
message: "Internal error: dispatcher not available".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
};
let client_rpc_id_clone = client_rpc_id.clone();
let fut = async move {
dispatcher.list_jobs().await
};
ctx.spawn(
fut.into_actor(self)
.map(move |res, _act, ctx_inner| match res {
Ok(jobs) => {
let resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: Some(json!(jobs)),
error: None,
id: client_rpc_id_clone.clone(),
};
ctx_inner.text(serde_json::to_string(&resp).unwrap());
}
Err(e) => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32000,
message: format!("Failed to list jobs: {}", e),
data: None,
}),
id: client_rpc_id_clone,
};
ctx_inner.text(serde_json::to_string(&err_resp).unwrap());
}
})
.timeout(TASK_TIMEOUT_DURATION)
.map(move |res, _act, ctx_inner| {
if res.is_err() {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32000,
message: "Request timed out".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx_inner.text(serde_json::to_string(&err_resp).unwrap());
}
}),
);
}
pub fn handle_run_job(
&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: -32000,
message: "Authentication required".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
let circle_pk = match params.get("circle_pk").and_then(|v| v.as_str()) {
Some(pk) => pk.to_string(),
None => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32602,
message: "Missing required parameter: circle_pk".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
};
let script_content = match params.get("script_content").and_then(|v| v.as_str()) {
Some(script) => script.to_string(),
None => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32602,
message: "Missing required parameter: script_content".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
};
let dispatcher = match self.dispatcher.clone() {
Some(d) => d,
None => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32603,
message: "Internal error: dispatcher not available".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
};
let client_rpc_id_clone = client_rpc_id.clone();
let fut = async move {
dispatcher
.new_job()
.context_id(&circle_pk)
.script_type(ScriptType::RhaiSAL)
.script(&script_content)
.timeout(TASK_TIMEOUT_DURATION)
.await_response()
.await
};
ctx.spawn(
fut.into_actor(self)
.map(move |res, _act, ctx_inner| match res {
Ok(job_id) => {
let result = JobResult { job_id };
let resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: Some(serde_json::to_value(result).unwrap()),
error: None,
id: client_rpc_id_clone.clone(),
};
ctx_inner.text(serde_json::to_string(&resp).unwrap());
}
Err(e) => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32000,
message: format!("Failed to run job: {}", e),
data: None,
}),
id: client_rpc_id_clone,
};
ctx_inner.text(serde_json::to_string(&err_resp).unwrap());
}
})
.timeout(TASK_TIMEOUT_DURATION)
.map(move |res, _act, ctx_inner| {
if res.is_err() {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32000,
message: "Request timed out".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx_inner.text(serde_json::to_string(&err_resp).unwrap());
}
}),
);
}
pub fn handle_get_job_output(
&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: -32000,
message: "Authentication required".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
let job_id = match params.get("job_id").and_then(|v| v.as_str()) {
Some(id) => id.to_string(),
None => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32602,
message: "Missing required parameter: job_id".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
};
let dispatcher = match self.dispatcher.clone() {
Some(d) => d,
None => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32603,
message: "Internal error: dispatcher not available".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
};
let client_rpc_id_clone = client_rpc_id.clone();
let fut = async move {
dispatcher.get_job_output(&job_id).await
};
ctx.spawn(
fut.into_actor(self)
.map(move |res, _act, ctx_inner| match res {
Ok(output) => {
let resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: Some(json!(output)),
error: None,
id: client_rpc_id_clone.clone(),
};
ctx_inner.text(serde_json::to_string(&resp).unwrap());
}
Err(e) => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32000,
message: format!("Failed to get job output: {}", e),
data: None,
}),
id: client_rpc_id_clone,
};
ctx_inner.text(serde_json::to_string(&err_resp).unwrap());
}
})
.timeout(TASK_TIMEOUT_DURATION)
.map(move |res, _act, ctx_inner| {
if res.is_err() {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32000,
message: "Request timed out".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx_inner.text(serde_json::to_string(&err_resp).unwrap());
}
}),
);
}
pub fn handle_get_job_logs(
&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: -32000,
message: "Authentication required".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
let job_id = match params.get("job_id").and_then(|v| v.as_str()) {
Some(id) => id.to_string(),
None => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32602,
message: "Missing required parameter: job_id".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
};
let dispatcher = match self.dispatcher.clone() {
Some(d) => d,
None => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32603,
message: "Internal error: dispatcher not available".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
};
let client_rpc_id_clone = client_rpc_id.clone();
let fut = async move {
dispatcher.get_job_logs(&job_id).await
};
ctx.spawn(
fut.into_actor(self)
.map(move |res, _act, ctx_inner| match res {
Ok(logs) => {
let result = json!({ "logs": logs });
let resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: Some(result),
error: None,
id: client_rpc_id_clone.clone(),
};
ctx_inner.text(serde_json::to_string(&resp).unwrap());
}
Err(e) => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32000,
message: format!("Failed to get job logs: {}", e),
data: None,
}),
id: client_rpc_id_clone,
};
ctx_inner.text(serde_json::to_string(&err_resp).unwrap());
}
})
.timeout(TASK_TIMEOUT_DURATION)
.map(move |res, _act, ctx_inner| {
if res.is_err() {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32000,
message: "Request timed out".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx_inner.text(serde_json::to_string(&err_resp).unwrap());
}
}),
);
}
pub fn handle_stop_job(
&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: -32000,
message: "Authentication required".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
let job_id = match params.get("job_id").and_then(|v| v.as_str()) {
Some(id) => id.to_string(),
None => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32602,
message: "Missing required parameter: job_id".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
};
let dispatcher = match self.dispatcher.clone() {
Some(d) => d,
None => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32603,
message: "Internal error: dispatcher not available".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
};
let client_rpc_id_clone = client_rpc_id.clone();
let fut = async move {
dispatcher.stop_job(&job_id).await
};
ctx.spawn(
fut.into_actor(self)
.map(move |res, _act, ctx_inner| match res {
Ok(_) => {
let resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: Some(json!(null)),
error: None,
id: client_rpc_id_clone.clone(),
};
ctx_inner.text(serde_json::to_string(&resp).unwrap());
}
Err(e) => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32000,
message: format!("Failed to stop job: {}", e),
data: None,
}),
id: client_rpc_id_clone,
};
ctx_inner.text(serde_json::to_string(&err_resp).unwrap());
}
})
.timeout(TASK_TIMEOUT_DURATION)
.map(move |res, _act, ctx_inner| {
if res.is_err() {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32000,
message: "Request timed out".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx_inner.text(serde_json::to_string(&err_resp).unwrap());
}
}),
);
}
pub fn handle_delete_job(
&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: -32000,
message: "Authentication required".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
let job_id = match params.get("job_id").and_then(|v| v.as_str()) {
Some(id) => id.to_string(),
None => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32602,
message: "Missing required parameter: job_id".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
};
let dispatcher = match self.dispatcher.clone() {
Some(d) => d,
None => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32603,
message: "Internal error: dispatcher not available".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
};
let client_rpc_id_clone = client_rpc_id.clone();
let fut = async move {
dispatcher.delete_job(&job_id).await
};
ctx.spawn(
fut.into_actor(self)
.map(move |res, _act, ctx_inner| match res {
Ok(_) => {
let resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: Some(json!(null)),
error: None,
id: client_rpc_id_clone.clone(),
};
ctx_inner.text(serde_json::to_string(&resp).unwrap());
}
Err(e) => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32000,
message: format!("Failed to delete job: {}", e),
data: None,
}),
id: client_rpc_id_clone,
};
ctx_inner.text(serde_json::to_string(&err_resp).unwrap());
}
})
.timeout(TASK_TIMEOUT_DURATION)
.map(move |res, _act, ctx_inner| {
if res.is_err() {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32000,
message: "Request timed out".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx_inner.text(serde_json::to_string(&err_resp).unwrap());
}
}),
);
}
pub fn handle_clear_all_jobs(
&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: -32000,
message: "Authentication required".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
let dispatcher = match self.dispatcher.clone() {
Some(d) => d,
None => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32603,
message: "Internal error: dispatcher not available".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
};
let client_rpc_id_clone = client_rpc_id.clone();
let fut = async move {
dispatcher.clear_all_jobs().await
};
ctx.spawn(
fut.into_actor(self)
.map(move |res, _act, ctx_inner| match res {
Ok(_) => {
let resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: Some(json!(null)),
error: None,
id: client_rpc_id_clone.clone(),
};
ctx_inner.text(serde_json::to_string(&resp).unwrap());
}
Err(e) => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32000,
message: format!("Failed to clear jobs: {}", e),
data: None,
}),
id: client_rpc_id_clone,
};
ctx_inner.text(serde_json::to_string(&err_resp).unwrap());
}
})
.timeout(TASK_TIMEOUT_DURATION)
.map(move |res, _act, ctx_inner| {
if res.is_err() {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32000,
message: "Request timed out".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx_inner.text(serde_json::to_string(&err_resp).unwrap());
}
}),
);
}
}

View File

@@ -3,7 +3,8 @@ 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 hero_dispatcher::{Dispatcher, DispatcherBuilder, DispatcherError};
use hero_job::{Job, JobStatus};
use rustls::pki_types::PrivateKeyDer;
use rustls::ServerConfig as RustlsServerConfig;
use rustls_pemfile::{certs, pkcs8_private_keys};
@@ -29,10 +30,13 @@ static AUTHENTICATED_CONNECTIONS: Lazy<Mutex<HashMap<Addr<Server>, String>>> =
mod auth;
mod builder;
mod config;
mod handler;
mod job_handlers;
use crate::auth::{generate_nonce, NonceResponse};
pub use crate::builder::ServerBuilder;
pub use crate::config::{ServerConfig, ConfigError};
// Re-export server handle type for external use
pub type ServerHandle = actix_web::dev::ServerHandle;
@@ -100,6 +104,64 @@ struct FetchNonceParams {
pubkey: String,
}
// Job management parameter structures
#[derive(Debug, Serialize, Deserialize)]
struct CreateJobParams {
job: Job,
}
#[derive(Debug, Serialize, Deserialize)]
struct RunJobParams {
job: Job,
}
#[derive(Debug, Serialize, Deserialize)]
struct JobIdParams {
job_id: String,
}
// Job management result structures
#[derive(Debug, Serialize, Deserialize)]
struct CreateJobResult {
job_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct RunJobResult {
job_id: String,
output: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct JobStatusResult {
status: JobStatus,
}
#[derive(Debug, Serialize, Deserialize)]
struct JobOutputResult {
output: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct JobLogsResult {
logs: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct ListJobsResult {
jobs: Vec<Job>,
}
#[derive(Debug, Serialize, Deserialize)]
struct SuccessResult {
success: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct ClearJobsResult {
deleted_count: usize,
}
impl Actor for Server {
type Context = ws::WebsocketContext<Self>;
@@ -142,11 +204,14 @@ pub struct Server {
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,
/// Map of circle IDs to vectors of public keys that are members of that circle
pub circles: HashMap<String, Vec<String>>,
nonce_store: HashMap<String, NonceResponse>,
authenticated_pubkey: Option<String>,
pub dispatcher: Option<Dispatcher>,
}
impl Server {
@@ -250,33 +315,34 @@ impl Server {
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) => {
// Extract pubkey string directly from params
let pubkey = match params.as_str() {
Some(pk) => pk.to_string(),
None => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32602,
message: format!("Invalid parameters for fetch_nonce: {}", e),
message: "Invalid parameters for fetch_nonce: expected string pubkey".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
return;
}
}
};
let nonce_response = generate_nonce();
self.nonce_store.insert(pubkey, nonce_response.clone());
let resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: Some(serde_json::to_value(&nonce_response.nonce).unwrap()),
error: None,
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&resp).unwrap());
}
fn handle_authenticate(
@@ -327,18 +393,41 @@ impl Server {
};
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());
// Check if the authenticated public key belongs to the circle
let is_circle_member = self.circles
.get(&self.circle_name)
.map(|members| members.contains(&auth_params.pubkey))
.unwrap_or(false);
if is_circle_member {
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 {
log::warn!("Auth failed for {}: Public key {} not a member of circle {}",
self.circle_name, auth_params.pubkey, self.circle_name);
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
result: None,
error: Some(JsonRpcError {
code: -32003,
message: "Public key not authorized for this circle".to_string(),
data: None,
}),
id: client_rpc_id,
};
ctx.text(serde_json::to_string(&err_resp).unwrap());
ctx.stop();
}
} else {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
@@ -459,7 +548,7 @@ impl Server {
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());
@@ -471,7 +560,7 @@ impl Server {
hero_dispatcher
.new_job()
.context_id(&circle_pk_clone)
.worker_id(&worker_id_clone)
.script_type(hero_dispatcher::ScriptType::RhaiSAL)
.script(&script_content)
.timeout(TASK_TIMEOUT_DURATION)
.await_response()
@@ -484,35 +573,16 @@ impl Server {
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());
}
Ok(output) => {
// The dispatcher returns the actual string output from job execution
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());
}
Err(e) => {
let (code, message) = match e {