implement unix and ws using jsonrpsee

This commit is contained in:
Timur Gordon
2025-08-07 11:56:49 +02:00
parent ce76f0a2f7
commit 831b25dbfa
11 changed files with 2019 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
[package]
name = "hero-openrpc-client"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "hero-openrpc-client"
path = "cmd/main.rs"
[dependencies]
# Core dependencies
tokio = { version = "1.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
thiserror = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
clap = { version = "4.0", features = ["derive"] }
# JSON-RPC dependencies
jsonrpsee = { version = "0.21", features = [
"client",
"macros"
] }
async-trait = "0.1"
# Hero dependencies
hero_job = { path = "../../../core/job" }
# Authentication and crypto
secp256k1 = { version = "0.28", features = ["rand", "recovery"] }
hex = "0.4"
sha2 = "0.10"
rand = "0.8"
# CLI utilities
dialoguer = "0.11"
colored = "2.0"
# Async utilities
futures = "0.3"

View File

@@ -0,0 +1,472 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use colored::*;
use dialoguer::{Input, Select, Confirm, MultiSelect};
use hero_job::ScriptType;
use hero_openrpc_client::{
AuthHelper, ClientTransport, HeroOpenRpcClient, JobParams,
};
use std::path::PathBuf;
use tracing::{error, info, Level};
use tracing_subscriber;
#[derive(Parser)]
#[command(name = "hero-openrpc-client")]
#[command(about = "Hero OpenRPC Client - Interactive JSON-RPC client")]
struct Cli {
#[command(subcommand)]
command: Commands,
/// Private key for authentication (hex format)
#[arg(long)]
private_key: Option<String>,
/// Generate a new private key and exit
#[arg(long)]
generate_key: bool,
/// Log level
#[arg(long, default_value = "info")]
log_level: String,
}
#[derive(Subcommand)]
enum Commands {
/// Connect to WebSocket server
Websocket {
/// Server URL
#[arg(long, default_value = "ws://127.0.0.1:9944")]
url: String,
},
/// Connect to Unix socket server
Unix {
/// Unix socket path
#[arg(long, default_value = "/tmp/hero-openrpc.sock")]
socket_path: PathBuf,
},
}
/// Available RPC methods with descriptions
#[derive(Debug, Clone)]
struct RpcMethod {
name: &'static str,
description: &'static str,
requires_auth: bool,
}
const RPC_METHODS: &[RpcMethod] = &[
RpcMethod {
name: "fetch_nonce",
description: "Fetch a nonce for authentication",
requires_auth: false,
},
RpcMethod {
name: "authenticate",
description: "Authenticate with public key and signature",
requires_auth: false,
},
RpcMethod {
name: "whoami",
description: "Get authentication status and user information",
requires_auth: true,
},
RpcMethod {
name: "play",
description: "Execute a Rhai script immediately",
requires_auth: true,
},
RpcMethod {
name: "create_job",
description: "Create a new job without starting it",
requires_auth: true,
},
RpcMethod {
name: "start_job",
description: "Start a previously created job",
requires_auth: true,
},
RpcMethod {
name: "run_job",
description: "Create and run a job, returning result when complete",
requires_auth: true,
},
RpcMethod {
name: "get_job_status",
description: "Get the current status of a job",
requires_auth: true,
},
RpcMethod {
name: "get_job_output",
description: "Get the output of a completed job",
requires_auth: true,
},
RpcMethod {
name: "get_job_logs",
description: "Get the logs of a job",
requires_auth: true,
},
RpcMethod {
name: "list_jobs",
description: "List all jobs in the system",
requires_auth: true,
},
RpcMethod {
name: "stop_job",
description: "Stop a running job",
requires_auth: true,
},
RpcMethod {
name: "delete_job",
description: "Delete a job from the system",
requires_auth: true,
},
RpcMethod {
name: "clear_all_jobs",
description: "Clear all jobs from the system",
requires_auth: true,
},
];
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
// Initialize tracing
let log_level = match cli.log_level.to_lowercase().as_str() {
"trace" => Level::TRACE,
"debug" => Level::DEBUG,
"info" => Level::INFO,
"warn" => Level::WARN,
"error" => Level::ERROR,
_ => Level::INFO,
};
tracing_subscriber::fmt()
.with_max_level(log_level)
.init();
// Handle key generation
if cli.generate_key {
let auth_helper = AuthHelper::generate()?;
println!("{}", "Generated new private key:".green().bold());
println!("Private Key: {}", auth_helper.private_key_hex().yellow());
println!("Public Key: {}", auth_helper.public_key_hex().cyan());
println!();
println!("{}", "Save the private key securely and use it with --private-key".bright_yellow());
return Ok(());
}
let transport = match cli.command {
Commands::Websocket { url } => {
println!("{} {}", "Connecting to WebSocket server:".green(), url.cyan());
ClientTransport::WebSocket(url)
}
Commands::Unix { socket_path } => {
println!("{} {:?}", "Connecting to Unix socket server:".green(), socket_path);
ClientTransport::Unix(socket_path)
}
};
// Connect to the server
let client = HeroOpenRpcClient::connect(transport).await?;
println!("{}", "Connected successfully!".green().bold());
// Handle authentication if private key is provided
let mut authenticated = false;
if let Some(private_key) = cli.private_key {
println!("{}", "Authenticating...".yellow());
match client.authenticate_with_key(&private_key).await {
Ok(true) => {
println!("{}", "Authentication successful!".green().bold());
authenticated = true;
}
Ok(false) => {
println!("{}", "Authentication failed!".red().bold());
}
Err(e) => {
error!("Authentication error: {}", e);
println!("{} {}", "Authentication error:".red().bold(), e);
}
}
} else {
println!("{}", "No private key provided. Some methods will require authentication.".yellow());
println!("{}", "Use --generate-key to create a new key or --private-key to use an existing one.".bright_yellow());
}
println!();
// Interactive loop
loop {
// Filter methods based on authentication status
let available_methods: Vec<&RpcMethod> = RPC_METHODS
.iter()
.filter(|method| !method.requires_auth || authenticated)
.collect();
if available_methods.is_empty() {
println!("{}", "No methods available. Please authenticate first.".red());
break;
}
// Display method selection
let method_names: Vec<String> = available_methods
.iter()
.map(|method| {
if method.requires_auth && !authenticated {
format!("{} {} (requires auth)", method.name.red(), method.description)
} else {
format!("{} {}", method.name.green(), method.description)
}
})
.collect();
let selection = Select::new()
.with_prompt("Select an RPC method to call")
.items(&method_names)
.default(0)
.interact_opt()?;
let Some(selection) = selection else {
println!("{}", "Goodbye!".cyan());
break;
};
let selected_method = available_methods[selection];
println!();
println!("{} {}", "Selected method:".bold(), selected_method.name.green());
// Handle method-specific parameter collection and execution
match execute_method(&client, selected_method.name).await {
Ok(_) => {}
Err(e) => {
error!("Method execution failed: {}", e);
println!("{} {}", "Error:".red().bold(), e);
}
}
println!();
// Ask if user wants to continue
if !Confirm::new()
.with_prompt("Do you want to call another method?")
.default(true)
.interact()?
{
break;
}
println!();
}
println!("{}", "Goodbye!".cyan().bold());
Ok(())
}
async fn execute_method(client: &HeroOpenRpcClient, method_name: &str) -> Result<()> {
match method_name {
"fetch_nonce" => {
let pubkey: String = Input::new()
.with_prompt("Public key (hex)")
.interact_text()?;
let result = client.fetch_nonce(pubkey).await?;
println!("{} {}", "Nonce:".green().bold(), result.yellow());
}
"authenticate" => {
let pubkey: String = Input::new()
.with_prompt("Public key (hex)")
.interact_text()?;
let signature: String = Input::new()
.with_prompt("Signature (hex)")
.interact_text()?;
let result = client.authenticate(pubkey, signature, nonce).await?;
println!("{} {}", "Authentication result:".green().bold(),
if result { "Success".green() } else { "Failed".red() });
}
"whoami" => {
let result = client.whoami().await?;
println!("{} {}", "User info:".green().bold(),
serde_json::to_string_pretty(&result)?.cyan());
}
"play" => {
let script: String = Input::new()
.with_prompt("Rhai script to execute")
.interact_text()?;
let result = client.play(script).await?;
println!("{} {}", "Script output:".green().bold(), result.output.cyan());
}
"create_job" => {
let script: String = Input::new()
.with_prompt("Script content")
.interact_text()?;
let script_types = ["HeroScript", "RhaiSAL", "RhaiDSL"];
let script_type_selection = Select::new()
.with_prompt("Script type")
.items(&script_types)
.default(0)
.interact()?;
let script_type = match script_type_selection {
0 => ScriptType::HeroScript,
1 => ScriptType::RhaiSAL,
2 => ScriptType::RhaiDSL,
_ => ScriptType::HeroScript,
};
let add_prerequisites = Confirm::new()
.with_prompt("Add prerequisites?")
.default(false)
.interact()?;
let prerequisites = if add_prerequisites {
let prereq_input: String = Input::new()
.with_prompt("Prerequisites (comma-separated job IDs)")
.interact_text()?;
Some(prereq_input.split(',').map(|s| s.trim().to_string()).collect())
} else {
None
};
let job_params = JobParams {
script,
script_type,
prerequisites,
};
let result = client.create_job(job_params).await?;
println!("{} {}", "Created job ID:".green().bold(), result.yellow());
}
"start_job" => {
let job_id: String = Input::new()
.with_prompt("Job ID to start")
.interact_text()?;
let result = client.start_job(job_id).await?;
println!("{} {}", "Start result:".green().bold(),
if result.success { "Success".green() } else { "Failed".red() });
}
"run_job" => {
let script: String = Input::new()
.with_prompt("Script content")
.interact_text()?;
let script_types = ["HeroScript", "RhaiSAL", "RhaiDSL"];
let script_type_selection = Select::new()
.with_prompt("Script type")
.items(&script_types)
.default(0)
.interact()?;
let script_type = match script_type_selection {
0 => ScriptType::HeroScript,
1 => ScriptType::RhaiSAL,
2 => ScriptType::RhaiDSL,
_ => ScriptType::HeroScript,
};
let add_prerequisites = Confirm::new()
.with_prompt("Add prerequisites?")
.default(false)
.interact()?;
let prerequisites = if add_prerequisites {
let prereq_input: String = Input::new()
.with_prompt("Prerequisites (comma-separated job IDs)")
.interact_text()?;
Some(prereq_input.split(',').map(|s| s.trim().to_string()).collect())
} else {
None
};
let result = client.run_job(script, script_type, prerequisites).await?;
println!("{} {}", "Job result:".green().bold(), result.cyan());
}
"get_job_status" => {
let job_id: String = Input::new()
.with_prompt("Job ID")
.interact_text()?;
let result = client.get_job_status(job_id).await?;
println!("{} {:?}", "Job status:".green().bold(), result);
}
"get_job_output" => {
let job_id: String = Input::new()
.with_prompt("Job ID")
.interact_text()?;
let result = client.get_job_output(job_id).await?;
println!("{} {}", "Job output:".green().bold(), result.cyan());
}
"get_job_logs" => {
let job_id: String = Input::new()
.with_prompt("Job ID")
.interact_text()?;
let result = client.get_job_logs(job_id).await?;
println!("{} {}", "Job logs:".green().bold(), result.logs.cyan());
}
"list_jobs" => {
let result = client.list_jobs().await?;
println!("{}", "Jobs:".green().bold());
for job in result {
println!(" {} - {} ({:?})",
job.id().yellow(),
job.script_type(),
job.status()
);
}
}
"stop_job" => {
let job_id: String = Input::new()
.with_prompt("Job ID to stop")
.interact_text()?;
client.stop_job(job_id.clone()).await?;
println!("{} {}", "Stopped job:".green().bold(), job_id.yellow());
}
"delete_job" => {
let job_id: String = Input::new()
.with_prompt("Job ID to delete")
.interact_text()?;
client.delete_job(job_id.clone()).await?;
println!("{} {}", "Deleted job:".green().bold(), job_id.yellow());
}
"clear_all_jobs" => {
let confirm = Confirm::new()
.with_prompt("Are you sure you want to clear ALL jobs?")
.default(false)
.interact()?;
if confirm {
client.clear_all_jobs().await?;
println!("{}", "Cleared all jobs".green().bold());
} else {
println!("{}", "Operation cancelled".yellow());
}
}
_ => {
println!("{} {}", "Unknown method:".red().bold(), method_name);
}
}
Ok(())
}

View File

@@ -0,0 +1,81 @@
use anyhow::{anyhow, Result};
use secp256k1::{Message, PublicKey, ecdsa::Signature, Secp256k1, SecretKey};
use sha2::{Digest, Sha256};
/// Helper for authentication operations
pub struct AuthHelper {
secret_key: SecretKey,
public_key: PublicKey,
secp: Secp256k1<secp256k1::All>,
}
impl AuthHelper {
/// Create a new auth helper from a private key hex string
pub fn new(private_key_hex: &str) -> Result<Self> {
let secp = Secp256k1::new();
let secret_key_bytes = hex::decode(private_key_hex)
.map_err(|_| anyhow!("Invalid private key hex format"))?;
let secret_key = SecretKey::from_slice(&secret_key_bytes)
.map_err(|_| anyhow!("Invalid private key"))?;
let public_key = PublicKey::from_secret_key(&secp, &secret_key);
Ok(Self {
secret_key,
public_key,
secp,
})
}
/// Generate a new random private key
pub fn generate() -> Result<Self> {
let secp = Secp256k1::new();
let (secret_key, public_key) = secp.generate_keypair(&mut rand::thread_rng());
Ok(Self {
secret_key,
public_key,
secp,
})
}
/// Get the public key as a hex string
pub fn public_key_hex(&self) -> String {
hex::encode(self.public_key.serialize())
}
/// Get the private key as a hex string
pub fn private_key_hex(&self) -> String {
hex::encode(self.secret_key.secret_bytes())
}
/// Sign a message and return the signature as hex
pub fn sign_message(&self, message: &str) -> Result<String> {
let message_hash = Sha256::digest(message.as_bytes());
let message = Message::from_slice(&message_hash)
.map_err(|_| anyhow!("Failed to create message from hash"))?;
let signature = self.secp.sign_ecdsa(&message, &self.secret_key);
Ok(hex::encode(signature.serialize_compact()))
}
/// Verify a signature against a message
pub fn verify_signature(&self, message: &str, signature_hex: &str) -> Result<bool> {
let message_hash = Sha256::digest(message.as_bytes());
let message = Message::from_slice(&message_hash)
.map_err(|_| anyhow!("Failed to create message from hash"))?;
let signature_bytes = hex::decode(signature_hex)
.map_err(|_| anyhow!("Invalid signature hex format"))?;
let signature = Signature::from_compact(&signature_bytes)
.map_err(|_| anyhow!("Invalid signature format"))?;
match self.secp.verify_ecdsa(&message, &signature, &self.public_key) {
Ok(_) => Ok(true),
Err(_) => Ok(false),
}
}
}

View File

@@ -0,0 +1,212 @@
use anyhow::Result;
use async_trait::async_trait;
use hero_job::{Job, JobStatus, ScriptType};
use jsonrpsee::core::client::ClientT;
use jsonrpsee::core::ClientError;
use jsonrpsee::proc_macros::rpc;
use jsonrpsee::rpc_params;
use jsonrpsee::ws_client::{WsClient, WsClientBuilder};
use std::path::PathBuf;
use tracing::{error, info};
mod auth;
mod types;
pub use auth::*;
pub use types::*;
/// Transport configuration for the client
#[derive(Debug, Clone)]
pub enum ClientTransport {
WebSocket(String),
}
/// OpenRPC client trait defining all available methods
#[rpc(client)]
pub trait OpenRpcClient {
// Authentication methods
#[method(name = "fetch_nonce")]
async fn fetch_nonce(&self, pubkey: String) -> Result<String, ClientError>;
#[method(name = "authenticate")]
async fn authenticate(
&self,
pubkey: String,
signature: String,
nonce: String,
) -> Result<bool, ClientError>;
#[method(name = "whoami")]
async fn whoami(&self) -> Result<serde_json::Value, ClientError>;
// Script execution
#[method(name = "play")]
async fn play(&self, script: String) -> Result<PlayResult, ClientError>;
// Job management
#[method(name = "create_job")]
async fn create_job(&self, job: JobParams) -> Result<String, ClientError>;
#[method(name = "start_job")]
async fn start_job(&self, job_id: String) -> Result<StartJobResult, ClientError>;
#[method(name = "run_job")]
async fn run_job(
&self,
script: String,
script_type: ScriptType,
prerequisites: Option<Vec<String>>,
) -> Result<String, ClientError>;
#[method(name = "get_job_status")]
async fn get_job_status(&self, job_id: String) -> Result<JobStatus, ClientError>;
#[method(name = "get_job_output")]
async fn get_job_output(&self, job_id: String) -> Result<String, ClientError>;
#[method(name = "get_job_logs")]
async fn get_job_logs(&self, job_id: String) -> Result<JobLogsResult, ClientError>;
#[method(name = "list_jobs")]
async fn list_jobs(&self) -> Result<Vec<Job>, ClientError>;
#[method(name = "stop_job")]
async fn stop_job(&self, job_id: String) -> Result<(), ClientError>;
#[method(name = "delete_job")]
async fn delete_job(&self, job_id: String) -> Result<(), ClientError>;
#[method(name = "clear_all_jobs")]
async fn clear_all_jobs(&self) -> Result<(), ClientError>;
}
/// Wrapper client that can use WebSocket transport
pub struct HeroOpenRpcClient {
client: WsClient,
}
impl HeroOpenRpcClient {
/// Connect to the OpenRPC server using the specified transport
pub async fn connect(transport: ClientTransport) -> Result<Self> {
match transport {
ClientTransport::WebSocket(url) => {
info!("Connecting to WebSocket server at {}", url);
let client = WsClientBuilder::default()
.build(&url)
.await?;
Ok(Self { client })
}
}
}
/// Get the underlying client for making RPC calls
pub fn client(&self) -> &WsClient {
&self.client
}
/// Authenticate with the server using a private key
pub async fn authenticate_with_key(&self, private_key: &str) -> Result<bool> {
let auth_helper = AuthHelper::new(private_key)?;
// Get nonce
let pubkey = auth_helper.public_key_hex();
let nonce: String = self.client.fetch_nonce(pubkey.clone()).await?;
// Sign nonce
let signature = auth_helper.sign_message(&nonce)?;
// Authenticate
let result = self.client.authenticate(pubkey, signature, nonce).await?;
if result {
info!("Authentication successful");
} else {
error!("Authentication failed");
}
Ok(result)
}
}
// Implement delegation methods on HeroOpenRpcClient to use the generated trait methods
impl HeroOpenRpcClient {
/// Delegate to fetch_nonce on the underlying client
pub async fn fetch_nonce(&self, pubkey: String) -> Result<String, ClientError> {
self.client.fetch_nonce(pubkey).await
}
/// Delegate to authenticate on the underlying client
pub async fn authenticate(
&self,
pubkey: String,
signature: String,
nonce: String,
) -> Result<bool, ClientError> {
self.client.authenticate(pubkey, signature, nonce).await
}
/// Delegate to whoami on the underlying client
pub async fn whoami(&self) -> Result<serde_json::Value, ClientError> {
self.client.whoami().await
}
/// Delegate to play on the underlying client
pub async fn play(&self, script: String) -> Result<PlayResult, ClientError> {
self.client.play(script).await
}
/// Delegate to create_job on the underlying client
pub async fn create_job(&self, job: JobParams) -> Result<String, ClientError> {
self.client.create_job(job).await
}
/// Delegate to start_job on the underlying client
pub async fn start_job(&self, job_id: String) -> Result<StartJobResult, ClientError> {
self.client.start_job(job_id).await
}
/// Delegate to run_job on the underlying client
pub async fn run_job(
&self,
script: String,
script_type: ScriptType,
prerequisites: Option<Vec<String>>,
) -> Result<String, ClientError> {
self.client.run_job(script, script_type, prerequisites).await
}
/// Delegate to get_job_status on the underlying client
pub async fn get_job_status(&self, job_id: String) -> Result<JobStatus, ClientError> {
self.client.get_job_status(job_id).await
}
/// Delegate to get_job_output on the underlying client
pub async fn get_job_output(&self, job_id: String) -> Result<String, ClientError> {
self.client.get_job_output(job_id).await
}
/// Delegate to get_job_logs on the underlying client
pub async fn get_job_logs(&self, job_id: String) -> Result<JobLogsResult, ClientError> {
self.client.get_job_logs(job_id).await
}
/// Delegate to list_jobs on the underlying client
pub async fn list_jobs(&self) -> Result<Vec<Job>, ClientError> {
self.client.list_jobs().await
}
/// Delegate to stop_job on the underlying client
pub async fn stop_job(&self, job_id: String) -> Result<(), ClientError> {
self.client.stop_job(job_id).await
}
/// Delegate to delete_job on the underlying client
pub async fn delete_job(&self, job_id: String) -> Result<(), ClientError> {
self.client.delete_job(job_id).await
}
/// Delegate to clear_all_jobs on the underlying client
pub async fn clear_all_jobs(&self) -> Result<(), ClientError> {
self.client.clear_all_jobs().await
}
}

View File

@@ -0,0 +1,28 @@
use hero_job::ScriptType;
use serde::{Deserialize, Serialize};
/// Parameters for creating a job
#[derive(Debug, Serialize, Deserialize)]
pub struct JobParams {
pub script: String,
pub script_type: ScriptType,
pub prerequisites: Option<Vec<String>>,
}
/// Result of script execution
#[derive(Debug, Serialize, Deserialize)]
pub struct PlayResult {
pub output: String,
}
/// Result of starting a job
#[derive(Debug, Serialize, Deserialize)]
pub struct StartJobResult {
pub success: bool,
}
/// Result of getting job logs
#[derive(Debug, Serialize, Deserialize)]
pub struct JobLogsResult {
pub logs: String,
}