initial commit
This commit is contained in:
25
proxies/http/Cargo.toml
Normal file
25
proxies/http/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "hero-http-proxy"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
actix-web = "4.4"
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
log = "0.4"
|
||||
env_logger = "0.10"
|
||||
clap = { version = "4.0", features = ["derive"] }
|
||||
bytes = "1.5"
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
|
||||
futures-util = "0.3"
|
||||
thiserror = "1.0"
|
||||
url = "2.4"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
anyhow = "1.0"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
149
proxies/http/README.md
Normal file
149
proxies/http/README.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Hero HTTP Proxy
|
||||
|
||||
HTTP proxy server for converting webhook requests to WebSocket JSON-RPC calls to the Hero WebSocket server.
|
||||
|
||||
## Overview
|
||||
|
||||
This proxy server acts as a bridge between HTTP webhook endpoints (like Stripe, iDenfy) and the Hero WebSocket server. It receives HTTP webhook requests, verifies signatures, and forwards them as JSON-RPC calls to the WebSocket server.
|
||||
|
||||
## Features
|
||||
|
||||
- **Webhook Support**: Built-in support for Stripe and iDenfy webhooks
|
||||
- **Signature Verification**: HMAC-SHA256 signature verification for security
|
||||
- **Extensible Design**: Easy to add new webhook providers
|
||||
- **WebSocket Connection Pooling**: Reuses WebSocket connections for efficiency
|
||||
- **Configurable**: JSON-based configuration with environment variable support
|
||||
- **Health Checks**: Built-in health check endpoint
|
||||
|
||||
## Configuration
|
||||
|
||||
The proxy can be configured via a JSON configuration file or environment variables:
|
||||
|
||||
### Environment Variables
|
||||
- `STRIPE_WEBHOOK_SECRET`: Stripe webhook signing secret
|
||||
- `IDENFY_WEBHOOK_SECRET`: iDenfy webhook signing secret
|
||||
|
||||
### Configuration File Example
|
||||
```json
|
||||
{
|
||||
"webhooks": {
|
||||
"stripe": {
|
||||
"secret": "whsec_...",
|
||||
"signature_header": "stripe-signature",
|
||||
"verify_signature": true
|
||||
},
|
||||
"idenfy": {
|
||||
"secret": "your_idenfy_secret",
|
||||
"signature_header": "idenfy-signature",
|
||||
"verify_signature": true
|
||||
}
|
||||
},
|
||||
"websocket_timeout": 30,
|
||||
"max_retries": 3
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
```bash
|
||||
cargo run -- --port 8080 --websocket-url ws://localhost:3030
|
||||
```
|
||||
|
||||
### With Configuration File
|
||||
```bash
|
||||
cargo run -- --port 8080 --websocket-url ws://localhost:3030 --config config.json
|
||||
```
|
||||
|
||||
### Command Line Options
|
||||
- `--port, -p`: HTTP server port (default: 8080)
|
||||
- `--websocket-url, -w`: WebSocket server URL (default: ws://localhost:3030)
|
||||
- `--config, -c`: Configuration file path (optional)
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Webhook Endpoints
|
||||
- `POST /webhooks/stripe/{circle_pk}`: Stripe webhook handler
|
||||
- `POST /webhooks/idenfy/{circle_pk}`: iDenfy webhook handler
|
||||
|
||||
### Health Check
|
||||
- `GET /health`: Health check endpoint
|
||||
|
||||
## Adding New Webhook Providers
|
||||
|
||||
To add a new webhook provider:
|
||||
|
||||
1. **Add configuration** in `src/config.rs`:
|
||||
```rust
|
||||
webhooks.insert("newprovider".to_string(), WebhookConfig {
|
||||
secret: std::env::var("NEWPROVIDER_WEBHOOK_SECRET").unwrap_or_default(),
|
||||
signature_header: "newprovider-signature".to_string(),
|
||||
verify_signature: true,
|
||||
});
|
||||
```
|
||||
|
||||
2. **Add signature verification** in `src/webhook/signature.rs`:
|
||||
```rust
|
||||
pub fn verify_newprovider_signature(
|
||||
payload: &[u8],
|
||||
signature_header: &str,
|
||||
secret: &str,
|
||||
) -> Result<(), ProxyError> {
|
||||
// Implementation specific to the provider
|
||||
}
|
||||
```
|
||||
|
||||
3. **Add handler** in `src/webhook/handlers.rs`:
|
||||
```rust
|
||||
pub async fn handle_newprovider_webhook(
|
||||
req: HttpRequest,
|
||||
path: web::Path<String>,
|
||||
body: Bytes,
|
||||
data: web::Data<Arc<ProxyState>>,
|
||||
) -> ActixResult<HttpResponse> {
|
||||
// Handler implementation
|
||||
}
|
||||
```
|
||||
|
||||
4. **Register route** in `src/main.rs`:
|
||||
```rust
|
||||
.route("/newprovider/{circle_pk}", web::post().to(webhook::handlers::handle_newprovider_webhook))
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
HTTP Webhook → Signature Verification → JSON-RPC → WebSocket Server
|
||||
↓ ↓ ↓ ↓
|
||||
Stripe/iDenfy HMAC-SHA256 Check play method Hero Server
|
||||
```
|
||||
|
||||
The proxy maintains persistent WebSocket connections to the Hero server and forwards webhook events as `play` method calls with appropriate scripts (e.g., `stripe_webhook_received`, `idenfy_webhook_received`).
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **actix-web**: HTTP server framework
|
||||
- **tokio-tungstenite**: WebSocket client
|
||||
- **heromodels**: Hero project models (local dependency)
|
||||
- **serde**: JSON serialization
|
||||
- **hmac/sha2**: Signature verification
|
||||
- **clap**: Command line argument parsing
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Build
|
||||
cargo build
|
||||
|
||||
# Run tests
|
||||
cargo test
|
||||
|
||||
# Run with debug logging
|
||||
RUST_LOG=debug cargo run
|
||||
|
||||
# Format code
|
||||
cargo fmt
|
||||
|
||||
# Check for issues
|
||||
cargo clippy
|
||||
```
|
16
proxies/http/config.example.json
Normal file
16
proxies/http/config.example.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"webhooks": {
|
||||
"stripe": {
|
||||
"secret": "whsec_test_secret_replace_with_actual",
|
||||
"signature_header": "stripe-signature",
|
||||
"verify_signature": true
|
||||
},
|
||||
"idenfy": {
|
||||
"secret": "idenfy_test_secret_replace_with_actual",
|
||||
"signature_header": "idenfy-signature",
|
||||
"verify_signature": true
|
||||
}
|
||||
},
|
||||
"websocket_timeout": 30,
|
||||
"max_retries": 3
|
||||
}
|
62
proxies/http/src/config.rs
Normal file
62
proxies/http/src/config.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use anyhow::Result;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
/// Webhook configurations for different providers
|
||||
pub webhooks: HashMap<String, WebhookConfig>,
|
||||
/// Default timeout for WebSocket requests (in seconds)
|
||||
pub websocket_timeout: u64,
|
||||
/// Maximum retries for WebSocket connections
|
||||
pub max_retries: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebhookConfig {
|
||||
/// Secret key for signature verification
|
||||
pub secret: String,
|
||||
/// Signature header name
|
||||
pub signature_header: String,
|
||||
/// Whether signature verification is enabled
|
||||
pub verify_signature: bool,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
let mut webhooks = HashMap::new();
|
||||
|
||||
// Default Stripe configuration
|
||||
webhooks.insert("stripe".to_string(), WebhookConfig {
|
||||
secret: std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default(),
|
||||
signature_header: "stripe-signature".to_string(),
|
||||
verify_signature: true,
|
||||
});
|
||||
|
||||
// Default iDenfy configuration
|
||||
webhooks.insert("idenfy".to_string(), WebhookConfig {
|
||||
secret: std::env::var("IDENFY_WEBHOOK_SECRET").unwrap_or_default(),
|
||||
signature_header: "idenfy-signature".to_string(),
|
||||
verify_signature: true,
|
||||
});
|
||||
|
||||
Self {
|
||||
webhooks,
|
||||
websocket_timeout: 30,
|
||||
max_retries: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_file(path: &str) -> Result<Self> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let config: Config = serde_json::from_str(&content)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn get_webhook_config(&self, provider: &str) -> Option<&WebhookConfig> {
|
||||
self.webhooks.get(provider)
|
||||
}
|
||||
}
|
63
proxies/http/src/error.rs
Normal file
63
proxies/http/src/error.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use actix_web::{HttpResponse, ResponseError};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ProxyError {
|
||||
#[error("WebSocket connection error: {0}")]
|
||||
WebSocketError(String),
|
||||
|
||||
#[error("JSON-RPC error: {0}")]
|
||||
JsonRpcError(String),
|
||||
|
||||
#[error("Webhook signature verification failed: {0}")]
|
||||
SignatureVerificationError(String),
|
||||
|
||||
#[error("Invalid webhook payload: {0}")]
|
||||
InvalidPayload(String),
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
ConfigError(String),
|
||||
|
||||
#[error("Timeout error: {0}")]
|
||||
TimeoutError(String),
|
||||
|
||||
#[error("Serialization error: {0}")]
|
||||
SerializationError(#[from] serde_json::Error),
|
||||
|
||||
#[error("Network error: {0}")]
|
||||
NetworkError(#[from] reqwest::Error),
|
||||
|
||||
#[error("WebSocket error: {0}")]
|
||||
TungsteniteError(#[from] tokio_tungstenite::tungstenite::Error),
|
||||
}
|
||||
|
||||
impl ResponseError for ProxyError {
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
match self {
|
||||
ProxyError::SignatureVerificationError(_) => {
|
||||
HttpResponse::Unauthorized().json(serde_json::json!({
|
||||
"error": "signature_verification_failed",
|
||||
"message": self.to_string()
|
||||
}))
|
||||
}
|
||||
ProxyError::InvalidPayload(_) => {
|
||||
HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "invalid_payload",
|
||||
"message": self.to_string()
|
||||
}))
|
||||
}
|
||||
ProxyError::TimeoutError(_) => {
|
||||
HttpResponse::RequestTimeout().json(serde_json::json!({
|
||||
"error": "timeout",
|
||||
"message": self.to_string()
|
||||
}))
|
||||
}
|
||||
_ => {
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "internal_server_error",
|
||||
"message": self.to_string()
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
67
proxies/http/src/main.rs
Normal file
67
proxies/http/src/main.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use actix_web::{web, App, HttpServer, middleware::Logger};
|
||||
use clap::Parser;
|
||||
use log::info;
|
||||
use std::sync::Arc;
|
||||
|
||||
mod config;
|
||||
mod error;
|
||||
mod proxy;
|
||||
mod types;
|
||||
mod webhook;
|
||||
mod websocket;
|
||||
|
||||
use config::Config;
|
||||
use proxy::ProxyState;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "hero-http-proxy")]
|
||||
#[command(about = "HTTP proxy server for converting webhooks to WebSocket JSON-RPC calls")]
|
||||
struct Args {
|
||||
/// HTTP server port
|
||||
#[arg(short, long, default_value = "8080")]
|
||||
port: u16,
|
||||
|
||||
/// WebSocket server URL
|
||||
#[arg(short, long, default_value = "ws://localhost:3030")]
|
||||
websocket_url: String,
|
||||
|
||||
/// Configuration file path
|
||||
#[arg(short, long)]
|
||||
config: Option<String>,
|
||||
}
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
env_logger::init();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
// Load configuration
|
||||
let config = if let Some(config_path) = args.config {
|
||||
Config::from_file(&config_path).unwrap_or_else(|e| {
|
||||
eprintln!("Failed to load config from {}: {}", config_path, e);
|
||||
std::process::exit(1);
|
||||
})
|
||||
} else {
|
||||
Config::default()
|
||||
};
|
||||
|
||||
let proxy_state = Arc::new(ProxyState::new(args.websocket_url, config));
|
||||
|
||||
info!("Starting HTTP proxy server on port {}", args.port);
|
||||
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
.app_data(web::Data::new(proxy_state.clone()))
|
||||
.wrap(Logger::default())
|
||||
.service(
|
||||
web::scope("/webhooks")
|
||||
.route("/stripe/{circle_pk}", web::post().to(webhook::handlers::handle_stripe_webhook))
|
||||
.route("/idenfy/{circle_pk}", web::post().to(webhook::handlers::handle_idenfy_webhook))
|
||||
)
|
||||
.route("/health", web::get().to(webhook::handlers::health_check))
|
||||
})
|
||||
.bind(("0.0.0.0", args.port))?
|
||||
.run()
|
||||
.await
|
||||
}
|
43
proxies/http/src/proxy.rs
Normal file
43
proxies/http/src/proxy.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use crate::config::Config;
|
||||
use crate::error::ProxyError;
|
||||
use crate::websocket::WebSocketClient;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Shared state for the HTTP proxy server
|
||||
pub struct ProxyState {
|
||||
/// WebSocket server URL
|
||||
pub websocket_url: String,
|
||||
/// Configuration
|
||||
pub config: Config,
|
||||
/// WebSocket client pool (for connection reuse)
|
||||
pub ws_client: Arc<Mutex<Option<WebSocketClient>>>,
|
||||
}
|
||||
|
||||
impl ProxyState {
|
||||
pub fn new(websocket_url: String, config: Config) -> Self {
|
||||
Self {
|
||||
websocket_url,
|
||||
config,
|
||||
ws_client: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get or create a WebSocket client connection
|
||||
pub async fn get_ws_client(&self) -> Result<WebSocketClient, ProxyError> {
|
||||
let mut client_guard = self.ws_client.lock().await;
|
||||
|
||||
// Check if we have a valid connection
|
||||
if let Some(ref client) = *client_guard {
|
||||
if client.is_connected().await {
|
||||
return Ok(client.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Create new connection
|
||||
let client = WebSocketClient::new(&self.websocket_url).await?;
|
||||
*client_guard = Some(client.clone());
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
}
|
48
proxies/http/src/types.rs
Normal file
48
proxies/http/src/types.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Stripe webhook event structure
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StripeWebhookEvent {
|
||||
/// The event ID
|
||||
pub id: String,
|
||||
/// The event type (e.g., "payment_intent.succeeded")
|
||||
#[serde(rename = "type")]
|
||||
pub event_type: String,
|
||||
/// The event data
|
||||
pub data: StripeEventData,
|
||||
/// When the event was created
|
||||
pub created: i64,
|
||||
/// Whether this is a live mode event
|
||||
pub livemode: bool,
|
||||
/// API version
|
||||
pub api_version: Option<String>,
|
||||
/// Number of times delivery was attempted
|
||||
pub pending_webhooks: Option<i32>,
|
||||
/// The request that caused this event
|
||||
pub request: Option<StripeEventRequest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StripeEventData {
|
||||
/// The object that triggered the event
|
||||
pub object: Value,
|
||||
/// Previous attributes (for update events)
|
||||
pub previous_attributes: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StripeEventRequest {
|
||||
/// The request ID
|
||||
pub id: Option<String>,
|
||||
/// The idempotency key
|
||||
pub idempotency_key: Option<String>,
|
||||
}
|
||||
|
||||
/// Generic webhook payload for providers that don't need specific parsing
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GenericWebhookPayload {
|
||||
/// Raw JSON payload
|
||||
#[serde(flatten)]
|
||||
pub data: Value,
|
||||
}
|
222
proxies/http/src/webhook/handlers.rs
Normal file
222
proxies/http/src/webhook/handlers.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
use crate::error::ProxyError;
|
||||
use crate::proxy::ProxyState;
|
||||
use crate::types::StripeWebhookEvent;
|
||||
use crate::webhook::signature::verify_webhook_signature;
|
||||
use actix_web::{web, HttpRequest, HttpResponse, ResponseError, Result as ActixResult};
|
||||
use bytes::Bytes;
|
||||
use log::{debug, error, info};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Extract signature from request headers
|
||||
fn extract_signature_header(
|
||||
headers: &actix_web::http::header::HeaderMap,
|
||||
header_name: &str,
|
||||
) -> Result<String, ProxyError> {
|
||||
match headers.get(header_name) {
|
||||
Some(sig) => match sig.to_str() {
|
||||
Ok(s) => Ok(s.to_string()),
|
||||
Err(_) => Err(ProxyError::SignatureVerificationError(format!(
|
||||
"Invalid {} header format", header_name
|
||||
))),
|
||||
},
|
||||
None => Err(ProxyError::SignatureVerificationError(format!(
|
||||
"Missing {} header", header_name
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle Stripe webhook requests
|
||||
pub async fn handle_stripe_webhook(
|
||||
req: HttpRequest,
|
||||
path: web::Path<String>,
|
||||
body: Bytes,
|
||||
data: web::Data<Arc<ProxyState>>,
|
||||
) -> ActixResult<HttpResponse> {
|
||||
let circle_pk = path.into_inner();
|
||||
|
||||
info!("Received Stripe webhook for circle: {}", circle_pk);
|
||||
|
||||
// Get Stripe webhook configuration
|
||||
let webhook_config = match data.config.get_webhook_config("stripe") {
|
||||
Some(config) => config,
|
||||
None => {
|
||||
error!("Stripe webhook configuration not found");
|
||||
return Ok(HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "configuration_error",
|
||||
"message": "Stripe webhook configuration not found"
|
||||
})));
|
||||
}
|
||||
};
|
||||
|
||||
// Verify signature if enabled
|
||||
if webhook_config.verify_signature {
|
||||
let signature = match extract_signature_header(req.headers(), &webhook_config.signature_header) {
|
||||
Ok(sig) => sig,
|
||||
Err(e) => {
|
||||
error!("Failed to extract Stripe signature: {}", e);
|
||||
return Ok(e.error_response());
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = verify_webhook_signature("stripe", &body, &signature, &webhook_config.secret) {
|
||||
error!("Stripe signature verification failed: {}", e);
|
||||
return Ok(e.error_response());
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the webhook payload
|
||||
let webhook_event: StripeWebhookEvent = match serde_json::from_slice(&body) {
|
||||
Ok(event) => event,
|
||||
Err(e) => {
|
||||
error!("Failed to parse Stripe webhook payload: {}", e);
|
||||
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "invalid_payload",
|
||||
"message": format!("Failed to parse webhook payload: {}", e)
|
||||
})));
|
||||
}
|
||||
};
|
||||
|
||||
info!("Processing Stripe webhook event: {} for circle: {}", webhook_event.event_type, circle_pk);
|
||||
|
||||
// Forward to WebSocket server via JSON-RPC
|
||||
match forward_stripe_webhook_to_ws(&data, &circle_pk, &webhook_event).await {
|
||||
Ok(result) => {
|
||||
info!("Successfully processed Stripe webhook for circle: {}", circle_pk);
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||
"status": "success",
|
||||
"message": "Webhook processed successfully",
|
||||
"result": result
|
||||
})))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to process Stripe webhook: {}", e);
|
||||
Ok(e.error_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward Stripe webhook to WebSocket server
|
||||
async fn forward_stripe_webhook_to_ws(
|
||||
proxy_state: &ProxyState,
|
||||
circle_id: &str,
|
||||
event: &StripeWebhookEvent,
|
||||
) -> Result<Value, ProxyError> {
|
||||
let ws_client = proxy_state.get_ws_client().await?;
|
||||
|
||||
// Create the script to execute
|
||||
let event_json = serde_json::to_string(event)
|
||||
.map_err(|e| ProxyError::InvalidPayload(format!("Failed to serialize Stripe event: {}", e)))?;
|
||||
|
||||
let script = format!(
|
||||
"stripe_webhook_received('{}', 'http_proxy', {})",
|
||||
circle_id, event_json
|
||||
);
|
||||
|
||||
debug!("Executing Stripe webhook script: {}", script);
|
||||
|
||||
// Execute via WebSocket JSON-RPC
|
||||
ws_client.execute_play(circle_id, "http_proxy", &script).await
|
||||
}
|
||||
|
||||
/// Handle iDenfy webhook requests
|
||||
pub async fn handle_idenfy_webhook(
|
||||
req: HttpRequest,
|
||||
path: web::Path<String>,
|
||||
body: Bytes,
|
||||
data: web::Data<Arc<ProxyState>>,
|
||||
) -> ActixResult<HttpResponse> {
|
||||
let circle_pk = path.into_inner();
|
||||
|
||||
info!("Received iDenfy webhook for circle: {}", circle_pk);
|
||||
|
||||
// Get iDenfy webhook configuration
|
||||
let webhook_config = match data.config.get_webhook_config("idenfy") {
|
||||
Some(config) => config,
|
||||
None => {
|
||||
error!("iDenfy webhook configuration not found");
|
||||
return Ok(HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "configuration_error",
|
||||
"message": "iDenfy webhook configuration not found"
|
||||
})));
|
||||
}
|
||||
};
|
||||
|
||||
// Verify signature if enabled
|
||||
if webhook_config.verify_signature {
|
||||
let signature = match extract_signature_header(req.headers(), &webhook_config.signature_header) {
|
||||
Ok(sig) => sig,
|
||||
Err(e) => {
|
||||
error!("Failed to extract iDenfy signature: {}", e);
|
||||
return Ok(e.error_response());
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = verify_webhook_signature("idenfy", &body, &signature, &webhook_config.secret) {
|
||||
error!("iDenfy signature verification failed: {}", e);
|
||||
return Ok(e.error_response());
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the webhook payload as generic JSON
|
||||
let webhook_payload: Value = match serde_json::from_slice(&body) {
|
||||
Ok(payload) => payload,
|
||||
Err(e) => {
|
||||
error!("Failed to parse iDenfy webhook payload: {}", e);
|
||||
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "invalid_payload",
|
||||
"message": format!("Failed to parse webhook payload: {}", e)
|
||||
})));
|
||||
}
|
||||
};
|
||||
|
||||
info!("Processing iDenfy webhook for circle: {}", circle_pk);
|
||||
|
||||
// Forward to WebSocket server via JSON-RPC
|
||||
match forward_idenfy_webhook_to_ws(&data, &circle_pk, &webhook_payload).await {
|
||||
Ok(result) => {
|
||||
info!("Successfully processed iDenfy webhook for circle: {}", circle_pk);
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||
"status": "success",
|
||||
"message": "Webhook processed successfully",
|
||||
"result": result
|
||||
})))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to process iDenfy webhook: {}", e);
|
||||
Ok(e.error_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward iDenfy webhook to WebSocket server
|
||||
async fn forward_idenfy_webhook_to_ws(
|
||||
proxy_state: &ProxyState,
|
||||
circle_id: &str,
|
||||
payload: &Value,
|
||||
) -> Result<Value, ProxyError> {
|
||||
let ws_client = proxy_state.get_ws_client().await?;
|
||||
|
||||
// Create the script to execute
|
||||
let payload_json = serde_json::to_string(payload)
|
||||
.map_err(|e| ProxyError::InvalidPayload(format!("Failed to serialize iDenfy payload: {}", e)))?;
|
||||
|
||||
let script = format!(
|
||||
"idenfy_webhook_received('{}', 'http_proxy', {})",
|
||||
circle_id, payload_json
|
||||
);
|
||||
|
||||
debug!("Executing iDenfy webhook script: {}", script);
|
||||
|
||||
// Execute via WebSocket JSON-RPC
|
||||
ws_client.execute_play(circle_id, "http_proxy", &script).await
|
||||
}
|
||||
|
||||
/// Health check endpoint
|
||||
pub async fn health_check() -> ActixResult<HttpResponse> {
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||
"status": "ok",
|
||||
"service": "hero-http-proxy",
|
||||
"timestamp": chrono::Utc::now().to_rfc3339()
|
||||
})))
|
||||
}
|
2
proxies/http/src/webhook/mod.rs
Normal file
2
proxies/http/src/webhook/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod handlers;
|
||||
pub mod signature;
|
128
proxies/http/src/webhook/signature.rs
Normal file
128
proxies/http/src/webhook/signature.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use crate::error::ProxyError;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use hex;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Verify Stripe webhook signature
|
||||
pub fn verify_stripe_signature(
|
||||
payload: &[u8],
|
||||
signature_header: &str,
|
||||
secret: &str,
|
||||
) -> Result<(), ProxyError> {
|
||||
// Parse the signature header (format: "t=timestamp,v1=signature")
|
||||
let mut timestamp = None;
|
||||
let mut signature = None;
|
||||
|
||||
for part in signature_header.split(',') {
|
||||
if let Some((key, value)) = part.split_once('=') {
|
||||
match key {
|
||||
"t" => timestamp = Some(value),
|
||||
"v1" => signature = Some(value),
|
||||
_ => {} // Ignore unknown parts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let timestamp = timestamp.ok_or_else(|| {
|
||||
ProxyError::SignatureVerificationError("Missing timestamp in signature header".to_string())
|
||||
})?;
|
||||
|
||||
let expected_signature = signature.ok_or_else(|| {
|
||||
ProxyError::SignatureVerificationError("Missing signature in signature header".to_string())
|
||||
})?;
|
||||
|
||||
// Create the signed payload (timestamp.payload)
|
||||
let signed_payload = format!("{}.{}", timestamp, String::from_utf8_lossy(payload));
|
||||
|
||||
// Calculate HMAC-SHA256
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
|
||||
.map_err(|e| ProxyError::SignatureVerificationError(format!("Invalid secret: {}", e)))?;
|
||||
|
||||
mac.update(signed_payload.as_bytes());
|
||||
let computed_signature = hex::encode(mac.finalize().into_bytes());
|
||||
|
||||
// Compare signatures
|
||||
if computed_signature != expected_signature {
|
||||
return Err(ProxyError::SignatureVerificationError(
|
||||
"Signature verification failed".to_string()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify iDenfy webhook signature
|
||||
pub fn verify_idenfy_signature(
|
||||
payload: &[u8],
|
||||
signature_header: &str,
|
||||
secret: &str,
|
||||
) -> Result<(), ProxyError> {
|
||||
// iDenfy uses HMAC-SHA256 with the raw payload
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
|
||||
.map_err(|e| ProxyError::SignatureVerificationError(format!("Invalid secret: {}", e)))?;
|
||||
|
||||
mac.update(payload);
|
||||
let computed_signature = hex::encode(mac.finalize().into_bytes());
|
||||
|
||||
// Compare signatures (case-insensitive)
|
||||
if computed_signature.to_lowercase() != signature_header.to_lowercase() {
|
||||
return Err(ProxyError::SignatureVerificationError(
|
||||
"Signature verification failed".to_string()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generic signature verification function
|
||||
pub fn verify_webhook_signature(
|
||||
provider: &str,
|
||||
payload: &[u8],
|
||||
signature_header: &str,
|
||||
secret: &str,
|
||||
) -> Result<(), ProxyError> {
|
||||
match provider {
|
||||
"stripe" => verify_stripe_signature(payload, signature_header, secret),
|
||||
"idenfy" => verify_idenfy_signature(payload, signature_header, secret),
|
||||
_ => Err(ProxyError::SignatureVerificationError(
|
||||
format!("Unsupported webhook provider: {}", provider)
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_stripe_signature_verification() {
|
||||
let payload = b"test payload";
|
||||
let secret = "test_secret";
|
||||
let timestamp = "1234567890";
|
||||
|
||||
// Create a valid signature
|
||||
let signed_payload = format!("{}.{}", timestamp, String::from_utf8_lossy(payload));
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
|
||||
mac.update(signed_payload.as_bytes());
|
||||
let signature = hex::encode(mac.finalize().into_bytes());
|
||||
|
||||
let signature_header = format!("t={},v1={}", timestamp, signature);
|
||||
|
||||
assert!(verify_stripe_signature(payload, &signature_header, secret).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_idenfy_signature_verification() {
|
||||
let payload = b"test payload";
|
||||
let secret = "test_secret";
|
||||
|
||||
// Create a valid signature
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
|
||||
mac.update(payload);
|
||||
let signature = hex::encode(mac.finalize().into_bytes());
|
||||
|
||||
assert!(verify_idenfy_signature(payload, &signature, secret).is_ok());
|
||||
}
|
||||
}
|
192
proxies/http/src/websocket.rs
Normal file
192
proxies/http/src/websocket.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
use crate::error::ProxyError;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use log::{debug, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::{timeout, Duration};
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
|
||||
use url::Url;
|
||||
|
||||
type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonRpcRequest {
|
||||
pub jsonrpc: String,
|
||||
pub method: String,
|
||||
pub params: Value,
|
||||
pub id: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonRpcResponse {
|
||||
pub jsonrpc: String,
|
||||
pub result: Option<Value>,
|
||||
pub error: Option<JsonRpcError>,
|
||||
pub id: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonRpcError {
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
pub data: Option<Value>,
|
||||
}
|
||||
|
||||
/// WebSocket client for communicating with the Hero WebSocket server
|
||||
#[derive(Clone)]
|
||||
pub struct WebSocketClient {
|
||||
ws_stream: Arc<Mutex<Option<WsStream>>>,
|
||||
url: String,
|
||||
}
|
||||
|
||||
impl WebSocketClient {
|
||||
/// Create a new WebSocket client and connect to the server
|
||||
pub async fn new(url: &str) -> Result<Self, ProxyError> {
|
||||
let client = Self {
|
||||
ws_stream: Arc::new(Mutex::new(None)),
|
||||
url: url.to_string(),
|
||||
};
|
||||
|
||||
client.connect().await?;
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Connect to the WebSocket server
|
||||
async fn connect(&self) -> Result<(), ProxyError> {
|
||||
let url = Url::parse(&self.url)
|
||||
.map_err(|e| ProxyError::WebSocketError(format!("Invalid URL: {}", e)))?;
|
||||
|
||||
info!("Connecting to WebSocket server: {}", self.url);
|
||||
|
||||
let (ws_stream, _) = connect_async(url).await
|
||||
.map_err(|e| ProxyError::WebSocketError(format!("Connection failed: {}", e)))?;
|
||||
|
||||
let mut stream_guard = self.ws_stream.lock().await;
|
||||
*stream_guard = Some(ws_stream);
|
||||
|
||||
info!("Successfully connected to WebSocket server");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if the WebSocket connection is still active
|
||||
pub async fn is_connected(&self) -> bool {
|
||||
let stream_guard = self.ws_stream.lock().await;
|
||||
stream_guard.is_some()
|
||||
}
|
||||
|
||||
/// Send a JSON-RPC request and wait for response
|
||||
pub async fn send_jsonrpc_request(
|
||||
&self,
|
||||
method: &str,
|
||||
params: Value,
|
||||
request_id: Value,
|
||||
) -> Result<JsonRpcResponse, ProxyError> {
|
||||
let request = JsonRpcRequest {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
method: method.to_string(),
|
||||
params,
|
||||
id: request_id.clone(),
|
||||
};
|
||||
|
||||
let request_json = serde_json::to_string(&request)?;
|
||||
debug!("Sending JSON-RPC request: {}", request_json);
|
||||
|
||||
// Send the request
|
||||
{
|
||||
let mut stream_guard = self.ws_stream.lock().await;
|
||||
let stream = stream_guard.as_mut()
|
||||
.ok_or_else(|| ProxyError::WebSocketError("Not connected".to_string()))?;
|
||||
|
||||
stream.send(Message::Text(request_json)).await
|
||||
.map_err(|e| ProxyError::WebSocketError(format!("Send failed: {}", e)))?;
|
||||
}
|
||||
|
||||
// Wait for response with timeout
|
||||
let response = timeout(Duration::from_secs(30), self.receive_response(request_id)).await
|
||||
.map_err(|_| ProxyError::TimeoutError("WebSocket request timeout".to_string()))??;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Receive a JSON-RPC response matching the given request ID
|
||||
async fn receive_response(&self, expected_id: Value) -> Result<JsonRpcResponse, ProxyError> {
|
||||
loop {
|
||||
let message = {
|
||||
let mut stream_guard = self.ws_stream.lock().await;
|
||||
let stream = stream_guard.as_mut()
|
||||
.ok_or_else(|| ProxyError::WebSocketError("Not connected".to_string()))?;
|
||||
|
||||
stream.next().await
|
||||
.ok_or_else(|| ProxyError::WebSocketError("Connection closed".to_string()))?
|
||||
.map_err(|e| ProxyError::WebSocketError(format!("Receive failed: {}", e)))?
|
||||
};
|
||||
|
||||
match message {
|
||||
Message::Text(text) => {
|
||||
debug!("Received WebSocket message: {}", text);
|
||||
|
||||
let response: JsonRpcResponse = serde_json::from_str(&text)
|
||||
.map_err(|e| ProxyError::JsonRpcError(format!("Invalid JSON-RPC response: {}", e)))?;
|
||||
|
||||
// Check if this is the response we're waiting for
|
||||
if response.id == expected_id {
|
||||
return Ok(response);
|
||||
} else {
|
||||
warn!("Received response with unexpected ID: {:?}, expected: {:?}", response.id, expected_id);
|
||||
}
|
||||
}
|
||||
Message::Close(_) => {
|
||||
return Err(ProxyError::WebSocketError("Connection closed by server".to_string()));
|
||||
}
|
||||
Message::Ping(data) => {
|
||||
// Respond to ping with pong
|
||||
let mut stream_guard = self.ws_stream.lock().await;
|
||||
let stream = stream_guard.as_mut()
|
||||
.ok_or_else(|| ProxyError::WebSocketError("Not connected".to_string()))?;
|
||||
|
||||
stream.send(Message::Pong(data)).await
|
||||
.map_err(|e| ProxyError::WebSocketError(format!("Pong failed: {}", e)))?;
|
||||
}
|
||||
_ => {
|
||||
// Ignore other message types
|
||||
debug!("Ignoring WebSocket message type: {:?}", message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a "play" command via JSON-RPC
|
||||
pub async fn execute_play(
|
||||
&self,
|
||||
circle_id: &str,
|
||||
caller_id: &str,
|
||||
script: &str,
|
||||
) -> Result<Value, ProxyError> {
|
||||
let params = serde_json::json!({
|
||||
"circle_id": circle_id,
|
||||
"caller_id": caller_id,
|
||||
"script": script
|
||||
});
|
||||
|
||||
let request_id = serde_json::json!(format!("play_{}", uuid::Uuid::new_v4()));
|
||||
|
||||
let response = self.send_jsonrpc_request("play", params, request_id).await?;
|
||||
|
||||
if let Some(error) = response.error {
|
||||
return Err(ProxyError::JsonRpcError(format!(
|
||||
"JSON-RPC error {}: {}",
|
||||
error.code,
|
||||
error.message
|
||||
)));
|
||||
}
|
||||
|
||||
response.result.ok_or_else(|| {
|
||||
ProxyError::JsonRpcError("No result in JSON-RPC response".to_string())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add uuid dependency to Cargo.toml for generating request IDs
|
Reference in New Issue
Block a user