rust_x402/
proxy.rs

1//! Proxy server implementation for x402 payments
2//!
3//! This module provides a reverse proxy server that adds x402 payment protection
4//! to any existing HTTP service.
5
6use crate::middleware::PaymentMiddlewareConfig;
7use crate::types::{FacilitatorConfig, Network};
8use crate::{Result, X402Error};
9use axum::{
10    extract::State,
11    http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode},
12    response::{IntoResponse, Response},
13    routing::any,
14    Router,
15};
16use rust_decimal::Decimal;
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19use std::str::FromStr;
20use tower::ServiceBuilder;
21use tower_http::trace::TraceLayer;
22use tracing::{info, warn};
23
24/// Configuration for the proxy server
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ProxyConfig {
27    /// Target URL to proxy requests to
28    pub target_url: String,
29    /// Payment amount in decimal units (e.g., 0.01 for 1 cent)
30    pub amount: f64,
31    /// Recipient wallet address
32    pub pay_to: String,
33    /// Payment description
34    pub description: Option<String>,
35    /// MIME type of the expected response
36    pub mime_type: Option<String>,
37    /// Maximum timeout in seconds
38    pub max_timeout_seconds: u32,
39    /// Facilitator URL
40    pub facilitator_url: String,
41    /// Whether to use testnet
42    pub testnet: bool,
43    /// Additional headers to forward to target
44    pub headers: HashMap<String, String>,
45    /// CDP API credentials (optional)
46    pub cdp_api_key_id: Option<String>,
47    pub cdp_api_key_secret: Option<String>,
48}
49
50impl Default for ProxyConfig {
51    fn default() -> Self {
52        Self {
53            target_url: String::new(),
54            amount: 0.0001,
55            pay_to: String::new(),
56            description: None,
57            mime_type: None,
58            max_timeout_seconds: 60,
59            facilitator_url: "https://x402.org/facilitator".to_string(),
60            testnet: true,
61            headers: HashMap::new(),
62            cdp_api_key_id: None,
63            cdp_api_key_secret: None,
64        }
65    }
66}
67
68impl ProxyConfig {
69    /// Load configuration from a JSON file
70    pub fn from_file(path: &str) -> Result<Self> {
71        let content = std::fs::read_to_string(path)
72            .map_err(|e| X402Error::config(format!("Failed to read config file: {}", e)))?;
73
74        let config: ProxyConfig = serde_json::from_str(&content)
75            .map_err(|e| X402Error::config(format!("Failed to parse config file: {}", e)))?;
76
77        config.validate()?;
78        Ok(config)
79    }
80
81    /// Load configuration from environment variables
82    pub fn from_env() -> Result<Self> {
83        let mut config = Self::default();
84
85        if let Ok(target_url) = std::env::var("TARGET_URL") {
86            config.target_url = target_url;
87        }
88
89        if let Ok(amount) = std::env::var("AMOUNT") {
90            config.amount = amount
91                .parse()
92                .map_err(|e| X402Error::config(format!("Invalid AMOUNT: {}", e)))?;
93        }
94
95        if let Ok(pay_to) = std::env::var("PAY_TO") {
96            config.pay_to = pay_to;
97        }
98
99        if let Ok(description) = std::env::var("DESCRIPTION") {
100            config.description = Some(description);
101        }
102
103        if let Ok(facilitator_url) = std::env::var("FACILITATOR_URL") {
104            config.facilitator_url = facilitator_url;
105        }
106
107        if let Ok(testnet) = std::env::var("TESTNET") {
108            config.testnet = testnet
109                .parse()
110                .map_err(|e| X402Error::config(format!("Invalid TESTNET: {}", e)))?;
111        }
112
113        if let Ok(cdp_api_key_id) = std::env::var("CDP_API_KEY_ID") {
114            config.cdp_api_key_id = Some(cdp_api_key_id);
115        }
116
117        if let Ok(cdp_api_key_secret) = std::env::var("CDP_API_KEY_SECRET") {
118            config.cdp_api_key_secret = Some(cdp_api_key_secret);
119        }
120
121        config.validate()?;
122        Ok(config)
123    }
124
125    /// Validate the configuration
126    pub fn validate(&self) -> Result<()> {
127        if self.target_url.is_empty() {
128            return Err(X402Error::config("TARGET_URL is required"));
129        }
130
131        if self.pay_to.is_empty() {
132            return Err(X402Error::config("PAY_TO is required"));
133        }
134
135        if self.amount <= 0.0 {
136            return Err(X402Error::config("AMOUNT must be positive"));
137        }
138
139        // Validate target URL
140        url::Url::parse(&self.target_url)
141            .map_err(|e| X402Error::config(format!("Invalid TARGET_URL: {}", e)))?;
142
143        // Validate facilitator URL
144        url::Url::parse(&self.facilitator_url)
145            .map_err(|e| X402Error::config(format!("Invalid FACILITATOR_URL: {}", e)))?;
146
147        Ok(())
148    }
149
150    /// Convert to payment middleware config
151    pub fn to_payment_config(&self) -> Result<PaymentMiddlewareConfig> {
152        let amount = Decimal::from_str(&self.amount.to_string())
153            .map_err(|e| X402Error::config(format!("Invalid amount: {}", e)))?;
154
155        let mut facilitator_config = FacilitatorConfig::new(&self.facilitator_url);
156
157        // Set up CDP authentication if credentials are provided
158        if let (Some(api_key_id), Some(api_key_secret)) =
159            (&self.cdp_api_key_id, &self.cdp_api_key_secret)
160        {
161            if !api_key_id.is_empty() && !api_key_secret.is_empty() {
162                let auth_headers =
163                    crate::facilitator::coinbase::create_auth_headers(api_key_id, api_key_secret);
164                facilitator_config = facilitator_config.with_auth_headers(Box::new(auth_headers));
165            }
166        }
167
168        let _network = if self.testnet {
169            Network::Testnet
170        } else {
171            Network::Mainnet
172        };
173
174        let mut config = PaymentMiddlewareConfig::new(amount, &self.pay_to)
175            .with_facilitator_config(facilitator_config)
176            .with_testnet(self.testnet)
177            .with_max_timeout_seconds(self.max_timeout_seconds);
178
179        if let Some(description) = &self.description {
180            config = config.with_description(description);
181        }
182
183        if let Some(mime_type) = &self.mime_type {
184            config = config.with_mime_type(mime_type);
185        }
186
187        Ok(config)
188    }
189}
190
191/// Proxy server state
192#[derive(Clone)]
193pub struct ProxyState {
194    config: ProxyConfig,
195    client: reqwest::Client,
196}
197
198impl ProxyState {
199    pub fn new(config: ProxyConfig) -> Result<Self> {
200        let client = reqwest::Client::builder()
201            .timeout(std::time::Duration::from_secs(30))
202            .build()
203            .map_err(|e| X402Error::config(format!("Failed to create HTTP client: {}", e)))?;
204
205        Ok(Self { config, client })
206    }
207}
208
209/// Create a proxy server with x402 payment protection
210pub fn create_proxy_server(config: ProxyConfig) -> Result<Router> {
211    let state = ProxyState::new(config.clone())?;
212
213    let app = Router::new()
214        .route("/*path", any(proxy_handler))
215        .with_state(state);
216
217    Ok(app)
218}
219
220/// Create a proxy server with tracing middleware
221pub fn create_proxy_server_with_tracing(config: ProxyConfig) -> Result<Router> {
222    let state = ProxyState::new(config.clone())?;
223
224    let app = Router::new()
225        .route("/*path", any(proxy_handler))
226        .with_state(state)
227        .layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()));
228
229    Ok(app)
230}
231
232/// Create a proxy server with x402 payment middleware
233pub fn create_proxy_server_with_payment(config: ProxyConfig) -> Result<Router> {
234    let state = ProxyState::new(config.clone())?;
235
236    // Create payment middleware from config
237    let payment_config = config.to_payment_config()?;
238    let payment_middleware = crate::middleware::PaymentMiddleware::new(
239        payment_config.amount,
240        payment_config.pay_to.clone(),
241    )
242    .with_facilitator_config(payment_config.facilitator_config.clone())
243    .with_testnet(payment_config.testnet)
244    .with_description(
245        payment_config
246            .description
247            .as_deref()
248            .unwrap_or("Proxy payment"),
249    );
250
251    let app = Router::new()
252        .route("/*path", any(proxy_handler_with_payment))
253        .with_state(state)
254        .layer(axum::middleware::from_fn_with_state(
255            payment_middleware,
256            payment_middleware_handler,
257        ));
258
259    Ok(app)
260}
261
262/// Payment middleware handler for proxy
263async fn payment_middleware_handler(
264    State(middleware): State<crate::middleware::PaymentMiddleware>,
265    request: axum::extract::Request,
266    next: axum::middleware::Next,
267) -> impl axum::response::IntoResponse {
268    match middleware.process_payment(request, next).await {
269        Ok(result) => match result {
270            crate::middleware::PaymentResult::Success { response, .. } => response,
271            crate::middleware::PaymentResult::PaymentRequired { response } => response,
272            crate::middleware::PaymentResult::VerificationFailed { response } => response,
273            crate::middleware::PaymentResult::SettlementFailed { response } => response,
274        },
275        Err(e) => (
276            axum::http::StatusCode::INTERNAL_SERVER_ERROR,
277            axum::Json(serde_json::json!({
278                "error": format!("Payment processing error: {}", e),
279                "x402Version": 1
280            })),
281        )
282            .into_response(),
283    }
284}
285
286/// Proxy handler with payment protection that forwards requests to the target server
287async fn proxy_handler_with_payment(
288    State(state): State<ProxyState>,
289    request: axum::extract::Request,
290) -> std::result::Result<Response, StatusCode> {
291    // This handler is called after payment middleware has verified the payment
292    proxy_handler(State(state), request).await
293}
294
295/// Proxy handler that forwards requests to the target server
296async fn proxy_handler(
297    State(state): State<ProxyState>,
298    request: axum::extract::Request,
299) -> std::result::Result<Response, StatusCode> {
300    let target_url = &state.config.target_url;
301    let client = &state.client;
302
303    // Extract the path from the request
304    let path = request.uri().path();
305    let query = request.uri().query().unwrap_or("");
306
307    // Build the target URL
308    let full_url = if query.is_empty() {
309        format!("{}{}", target_url, path)
310    } else {
311        format!("{}{}?{}", target_url, path, query)
312    };
313
314    info!("Proxying request to: {}", full_url);
315
316    // Create a new request to the target server
317    let method =
318        Method::from_str(request.method().as_str()).map_err(|_| StatusCode::BAD_REQUEST)?;
319
320    let mut target_request = client.request(method, &full_url);
321
322    // Copy essential headers
323    target_request = copy_essential_headers(request.headers(), target_request);
324
325    // Add custom headers from config
326    for (key, value) in &state.config.headers {
327        if let (Ok(name), Ok(val)) = (HeaderName::try_from(key), HeaderValue::try_from(value)) {
328            target_request = target_request.header(name, val);
329        }
330    }
331
332    // Copy request body if present
333    let body = axum::body::to_bytes(request.into_body(), usize::MAX)
334        .await
335        .map_err(|_| StatusCode::BAD_REQUEST)?;
336
337    if !body.is_empty() {
338        target_request = target_request.body(body);
339    }
340
341    // Execute the request
342    let response = target_request.send().await.map_err(|e| {
343        warn!("Failed to execute proxy request: {}", e);
344        StatusCode::BAD_GATEWAY
345    })?;
346
347    // Convert response
348    let status = response.status();
349    let headers = response.headers().clone();
350    let body = response
351        .bytes()
352        .await
353        .map_err(|_| StatusCode::BAD_GATEWAY)?;
354
355    let mut response_builder = Response::builder().status(status);
356
357    // Copy response headers
358    for (key, value) in headers.iter() {
359        if let Ok(header_name) = HeaderName::try_from(key.as_str()) {
360            response_builder = response_builder.header(header_name, value);
361        }
362    }
363
364    response_builder
365        .body(body.into())
366        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
367}
368
369/// Copy essential headers from the original request to the target request
370fn copy_essential_headers(
371    source_headers: &HeaderMap,
372    target_request: reqwest::RequestBuilder,
373) -> reqwest::RequestBuilder {
374    let essential_headers = [
375        "user-agent",
376        "accept",
377        "accept-language",
378        "accept-encoding",
379        "content-type",
380        "content-length",
381        "authorization",
382        "x-requested-with",
383    ];
384
385    let mut request = target_request;
386
387    for header_name in &essential_headers {
388        if let Some(value) = source_headers.get(*header_name) {
389            if let Ok(name) = HeaderName::try_from(*header_name) {
390                request = request.header(name, value);
391            }
392        }
393    }
394
395    request
396}
397
398/// Run a proxy server with the given configuration
399pub async fn run_proxy_server(config: ProxyConfig, port: u16) -> Result<()> {
400    let app = create_proxy_server_with_tracing(config)?;
401
402    let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port))
403        .await
404        .map_err(|e| X402Error::config(format!("Failed to bind to port {}: {}", port, e)))?;
405
406    info!("🚀 Proxy server running on port {}", port);
407    info!("💰 All requests will require payment");
408
409    axum::serve(listener, app)
410        .await
411        .map_err(|e| X402Error::config(format!("Server error: {}", e)))?;
412
413    Ok(())
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn test_proxy_config_default() {
422        let config = ProxyConfig::default();
423        assert_eq!(config.amount, 0.0001);
424        assert!(config.testnet);
425        assert_eq!(config.facilitator_url, "https://x402.org/facilitator");
426    }
427
428    #[test]
429    fn test_proxy_config_validation() {
430        let config = ProxyConfig {
431            target_url: "https://example.com".to_string(),
432            pay_to: "0x1234567890123456789012345678901234567890".to_string(),
433            ..Default::default()
434        };
435
436        let result = config.validate();
437        assert!(result.is_ok(), "Valid config should pass validation");
438
439        // Verify the config values are preserved
440        assert_eq!(config.target_url, "https://example.com");
441        assert_eq!(config.pay_to, "0x1234567890123456789012345678901234567890");
442        assert!(config.testnet, "Default should be testnet");
443    }
444
445    #[test]
446    fn test_proxy_config_validation_missing_target() {
447        let config = ProxyConfig::default();
448        let result = config.validate();
449        assert!(
450            result.is_err(),
451            "Config without target URL should fail validation"
452        );
453
454        // Verify the specific error type and message
455        let error_msg = result.unwrap_err().to_string();
456        assert!(
457            error_msg.contains("TARGET_URL is required"),
458            "Error should mention TARGET_URL is required - actual: {}",
459            error_msg
460        );
461    }
462
463    #[test]
464    fn test_proxy_config_validation_invalid_url() {
465        let config = ProxyConfig {
466            target_url: "not-a-url".to_string(),
467            pay_to: "0x1234567890123456789012345678901234567890".to_string(),
468            ..Default::default()
469        };
470
471        let result = config.validate();
472        assert!(
473            result.is_err(),
474            "Config with invalid URL should fail validation"
475        );
476
477        // Verify the specific error type and message
478        let error_msg = result.unwrap_err().to_string();
479        assert!(
480            error_msg.contains("invalid URL") || error_msg.contains("URL"),
481            "Error should mention invalid URL - actual: {}",
482            error_msg
483        );
484    }
485
486    #[test]
487    fn test_proxy_config_to_payment_config() {
488        let config = ProxyConfig {
489            target_url: "https://example.com".to_string(),
490            pay_to: "0x1234567890123456789012345678901234567890".to_string(),
491            amount: 0.01,
492            description: Some("Test payment".to_string()),
493            ..Default::default()
494        };
495
496        let payment_config = config.to_payment_config().unwrap();
497        assert_eq!(
498            payment_config.pay_to,
499            "0x1234567890123456789012345678901234567890"
500        );
501        assert!(payment_config.testnet);
502    }
503}