Skip to main content

scope/http/
ghola.rs

1//! Ghola sidecar HTTP client.
2//!
3//! Forwards all HTTP requests to a locally running
4//! [Ghola](https://github.com/robot-accomplice/ghola) sidecar
5//! (`127.0.0.1:18789`). When stealth mode is enabled, the sidecar
6//! applies temporal drift and ghost signing to every outgoing request.
7//!
8//! The sidecar is an external Go binary. If it is not already running,
9//! [`GholaHttpClient::ensure_ready`] will attempt to spawn it via
10//! `ghola --serve` and wait for the bridge to become reachable.
11
12use super::{HttpClient, Request, Response};
13use crate::error::ScopeError;
14use async_trait::async_trait;
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17use std::net::TcpStream;
18use std::process::Command;
19use std::sync::atomic::{AtomicU32, Ordering};
20use std::time::Duration;
21
22const SIDECAR_ADDR: &str = "127.0.0.1:18789";
23const SIDECAR_URL: &str = "http://127.0.0.1:18789";
24const SPAWN_TIMEOUT: Duration = Duration::from_secs(5);
25const POLL_INTERVAL: Duration = Duration::from_millis(100);
26
27/// PID of the sidecar we spawned (0 means none spawned by us).
28static SIDECAR_PID: AtomicU32 = AtomicU32::new(0);
29
30#[derive(Serialize)]
31struct BridgeRequest {
32    url: String,
33    method: String,
34    headers: HashMap<String, String>,
35    body: String,
36    drift: bool,
37    ghost: bool,
38    retries: i32,
39    buffer_size: u32,
40}
41
42#[derive(Deserialize)]
43struct BridgeResponse {
44    status_code: u16,
45    headers: HashMap<String, String>,
46    body: String,
47    #[serde(default)]
48    error: String,
49}
50
51/// HTTP client that forwards requests to the Ghola sidecar bridge
52/// running on `127.0.0.1:18789`. When `stealth` is `true`, the bridge
53/// applies temporal drift and ghost signing to every request.
54pub struct GholaHttpClient {
55    client: reqwest::Client,
56    stealth: bool,
57    buffer_size: u32,
58    base_url: String,
59}
60
61impl GholaHttpClient {
62    /// Creates a new client that talks to an already-running sidecar.
63    pub fn new(stealth: bool, buffer_size: u32) -> Result<Self, ScopeError> {
64        let client = reqwest::Client::builder()
65            .timeout(Duration::from_secs(30))
66            .build()
67            .map_err(|e| {
68                ScopeError::Network(format!("failed to build ghola bridge client: {e}"))
69            })?;
70        Ok(Self {
71            client,
72            stealth,
73            buffer_size,
74            base_url: SIDECAR_URL.to_string(),
75        })
76    }
77
78    /// Creates a client pointing at a custom URL (for testing).
79    #[cfg(test)]
80    pub fn with_base_url(
81        stealth: bool,
82        buffer_size: u32,
83        base_url: &str,
84    ) -> Result<Self, ScopeError> {
85        let client = reqwest::Client::builder()
86            .timeout(Duration::from_secs(5))
87            .build()
88            .map_err(|e| {
89                ScopeError::Network(format!("failed to build ghola bridge client: {e}"))
90            })?;
91        Ok(Self {
92            client,
93            stealth,
94            buffer_size,
95            base_url: base_url.to_string(),
96        })
97    }
98
99    /// Ensures the sidecar is reachable. If not, spawns `ghola --serve`
100    /// and waits for it to become ready. Returns a configured client.
101    pub async fn ensure_ready(stealth: bool, buffer_size: u32) -> Result<Self, ScopeError> {
102        if !is_bridge_running() {
103            spawn_sidecar()?;
104            wait_for_bridge(SPAWN_TIMEOUT).await?;
105        }
106        Self::new(stealth, buffer_size)
107    }
108}
109
110#[async_trait]
111impl HttpClient for GholaHttpClient {
112    async fn send(&self, request: Request) -> Result<Response, ScopeError> {
113        let bridge_req = BridgeRequest {
114            url: request.url,
115            method: request.method,
116            headers: request.headers,
117            body: request.body.unwrap_or_default(),
118            drift: self.stealth,
119            ghost: self.stealth,
120            retries: 0,
121            buffer_size: self.buffer_size,
122        };
123
124        let resp = self
125            .client
126            .post(&self.base_url)
127            .json(&bridge_req)
128            .send()
129            .await
130            .map_err(|e| ScopeError::Network(format!("failed to reach ghola sidecar: {e}")))?;
131
132        let bridge_resp: BridgeResponse = resp
133            .json()
134            .await
135            .map_err(|e| ScopeError::Network(format!("invalid sidecar response: {e}")))?;
136
137        if !bridge_resp.error.is_empty() {
138            return Err(ScopeError::Network(format!(
139                "sidecar error: {}",
140                bridge_resp.error
141            )));
142        }
143
144        Ok(Response {
145            status_code: bridge_resp.status_code,
146            headers: bridge_resp.headers,
147            body: bridge_resp.body,
148        })
149    }
150}
151
152/// Returns `true` if the `ghola` binary is reachable via PATH.
153pub fn ghola_in_path() -> bool {
154    Command::new("ghola")
155        .arg("--help")
156        .stdout(std::process::Stdio::null())
157        .stderr(std::process::Stdio::null())
158        .status()
159        .map(|s| s.success())
160        .unwrap_or(false)
161}
162
163fn is_bridge_running() -> bool {
164    SIDECAR_ADDR
165        .parse()
166        .ok()
167        .and_then(|addr| TcpStream::connect_timeout(&addr, Duration::from_millis(200)).ok())
168        .is_some()
169}
170
171fn spawn_sidecar() -> Result<(), ScopeError> {
172    let child = Command::new("ghola")
173        .arg("--serve")
174        .stdout(std::process::Stdio::null())
175        .stderr(std::process::Stdio::piped())
176        .spawn()
177        .map_err(|e| {
178            ScopeError::Network(format!(
179                "failed to spawn ghola --serve: {e}\n  \
180                 Install: go install github.com/robot-accomplice/ghola/cmd/ghola@latest\n  \
181                 Or download from: https://github.com/robot-accomplice/ghola/releases"
182            ))
183        })?;
184    SIDECAR_PID.store(child.id(), Ordering::Relaxed);
185    Ok(())
186}
187
188async fn wait_for_bridge(timeout: Duration) -> Result<(), ScopeError> {
189    let start = std::time::Instant::now();
190    while start.elapsed() < timeout {
191        if is_bridge_running() {
192            return Ok(());
193        }
194        tokio::time::sleep(POLL_INTERVAL).await;
195    }
196    Err(ScopeError::Network(format!(
197        "ghola sidecar did not become ready within {timeout:?}"
198    )))
199}
200
201// ============================================================================
202// Unit Tests
203// ============================================================================
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn test_bridge_request_serialization() {
211        let req = BridgeRequest {
212            url: "https://example.com".to_string(),
213            method: "GET".to_string(),
214            headers: HashMap::new(),
215            body: String::new(),
216            drift: true,
217            ghost: false,
218            retries: 3,
219            buffer_size: 4096,
220        };
221        let json = serde_json::to_string(&req).unwrap();
222        assert!(json.contains("\"drift\":true"));
223        assert!(json.contains("\"ghost\":false"));
224        assert!(json.contains("\"retries\":3"));
225        assert!(json.contains("\"buffer_size\":4096"));
226    }
227
228    #[test]
229    fn test_bridge_response_deserialization() {
230        let json = r#"{"status_code":200,"headers":{},"body":"ok","error":""}"#;
231        let resp: BridgeResponse = serde_json::from_str(json).unwrap();
232        assert_eq!(resp.status_code, 200);
233        assert_eq!(resp.body, "ok");
234        assert!(resp.error.is_empty());
235    }
236
237    #[test]
238    fn test_bridge_response_with_error() {
239        let json = r#"{"status_code":0,"headers":{},"body":"","error":"connection refused"}"#;
240        let resp: BridgeResponse = serde_json::from_str(json).unwrap();
241        assert_eq!(resp.error, "connection refused");
242    }
243
244    #[test]
245    fn test_bridge_response_missing_error_field() {
246        let json = r#"{"status_code":200,"headers":{},"body":"data"}"#;
247        let resp: BridgeResponse = serde_json::from_str(json).unwrap();
248        assert!(resp.error.is_empty());
249    }
250
251    #[test]
252    fn test_ghola_client_creation() {
253        let client = GholaHttpClient::new(true, 4096);
254        assert!(client.is_ok());
255    }
256
257    #[test]
258    fn test_ghola_client_creation_stealth_off() {
259        let client = GholaHttpClient::new(false, 4096);
260        assert!(client.is_ok());
261    }
262
263    #[test]
264    fn test_sidecar_pid_default_zero() {
265        assert_eq!(SIDECAR_PID.load(Ordering::Relaxed), 0);
266    }
267
268    #[test]
269    fn test_is_bridge_running_returns_bool() {
270        let result = is_bridge_running();
271        assert!(result == true || result == false);
272    }
273
274    #[test]
275    fn test_bridge_request_full_serialization() {
276        let mut headers = HashMap::new();
277        headers.insert("Authorization".to_string(), "Bearer tk".to_string());
278        let req = BridgeRequest {
279            url: "https://api.test.com/v1".to_string(),
280            method: "POST".to_string(),
281            headers,
282            body: r#"{"data":1}"#.to_string(),
283            drift: false,
284            ghost: true,
285            retries: 0,
286            buffer_size: 8192,
287        };
288        let json = serde_json::to_string(&req).unwrap();
289        assert!(json.contains("\"method\":\"POST\""));
290        assert!(json.contains("\"ghost\":true"));
291        assert!(json.contains("\"drift\":false"));
292        assert!(json.contains("\"retries\":0"));
293        assert!(json.contains("\"buffer_size\":8192"));
294        assert!(json.contains("Authorization"));
295    }
296
297    #[test]
298    fn test_bridge_response_roundtrip() {
299        let mut headers = HashMap::new();
300        headers.insert("content-type".to_string(), "application/json".to_string());
301        let json = serde_json::json!({
302            "status_code": 201,
303            "headers": headers,
304            "body": r#"{"id":42}"#,
305            "error": ""
306        });
307        let resp: BridgeResponse = serde_json::from_value(json).unwrap();
308        assert_eq!(resp.status_code, 201);
309        assert_eq!(resp.body, r#"{"id":42}"#);
310        assert!(resp.error.is_empty());
311        assert_eq!(
312            resp.headers.get("content-type").map(String::as_str),
313            Some("application/json")
314        );
315    }
316
317    #[tokio::test]
318    async fn test_wait_for_bridge_completes() {
319        let result = wait_for_bridge(Duration::from_millis(200)).await;
320        // Either succeeds (sidecar running) or fails with timeout
321        match result {
322            Ok(()) => {} // sidecar was already running
323            Err(e) => assert!(e.to_string().contains("did not become ready")),
324        }
325    }
326
327    #[tokio::test]
328    async fn test_send_to_mock_sidecar() {
329        let mut server = mockito::Server::new_async().await;
330        let mock = server
331            .mock("POST", "/")
332            .with_status(200)
333            .with_header("content-type", "application/json")
334            .with_body(
335                r#"{"status_code":200,"headers":{},"body":"{\"result\":\"ok\"}","error":""}"#,
336            )
337            .create_async()
338            .await;
339
340        let ghola = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
341
342        let req = Request::get("https://api.example.com/data");
343        let resp = ghola.send(req).await.unwrap();
344        assert_eq!(resp.status_code, 200);
345        assert_eq!(resp.body, r#"{"result":"ok"}"#);
346        mock.assert_async().await;
347    }
348
349    #[test]
350    fn test_ghola_in_path_returns_bool() {
351        let result = ghola_in_path();
352        assert!(result == true || result == false);
353    }
354
355    #[test]
356    fn test_sidecar_constants() {
357        assert_eq!(SIDECAR_ADDR, "127.0.0.1:18789");
358        assert_eq!(SIDECAR_URL, "http://127.0.0.1:18789");
359        assert_eq!(SPAWN_TIMEOUT, Duration::from_secs(5));
360        assert_eq!(POLL_INTERVAL, Duration::from_millis(100));
361    }
362
363    #[tokio::test]
364    async fn test_send_success_via_mock() {
365        let mut server = mockito::Server::new_async().await;
366        let mock = server
367            .mock("POST", "/")
368            .with_status(200)
369            .with_header("content-type", "application/json")
370            .with_body(r#"{"status_code":200,"headers":{"x-test":"yes"},"body":"{\"data\":42}","error":""}"#)
371            .create_async()
372            .await;
373
374        let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
375        let req = Request::get("https://api.example.com/v1");
376        let resp = client.send(req).await.unwrap();
377
378        assert_eq!(resp.status_code, 200);
379        assert!(resp.is_success());
380        assert_eq!(resp.body, r#"{"data":42}"#);
381        assert_eq!(resp.headers.get("x-test").map(String::as_str), Some("yes"));
382        mock.assert_async().await;
383    }
384
385    #[tokio::test]
386    async fn test_send_with_stealth_off() {
387        let mut server = mockito::Server::new_async().await;
388        let mock = server
389            .mock("POST", "/")
390            .with_status(200)
391            .with_header("content-type", "application/json")
392            .with_body(r#"{"status_code":200,"headers":{},"body":"ok","error":""}"#)
393            .create_async()
394            .await;
395
396        let client = GholaHttpClient::with_base_url(false, 4096, &server.url()).unwrap();
397        let req = Request::post_json("https://api.example.com", r#"{"q":1}"#);
398        let resp = client.send(req).await.unwrap();
399
400        assert_eq!(resp.status_code, 200);
401        assert_eq!(resp.body, "ok");
402        mock.assert_async().await;
403    }
404
405    #[tokio::test]
406    async fn test_send_bridge_error_response() {
407        let mut server = mockito::Server::new_async().await;
408        let mock = server
409            .mock("POST", "/")
410            .with_status(200)
411            .with_header("content-type", "application/json")
412            .with_body(r#"{"status_code":0,"headers":{},"body":"","error":"upstream timeout"}"#)
413            .create_async()
414            .await;
415
416        let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
417        let req = Request::get("https://api.example.com");
418        let result = client.send(req).await;
419
420        assert!(result.is_err());
421        assert!(result.unwrap_err().to_string().contains("upstream timeout"));
422        mock.assert_async().await;
423    }
424
425    #[tokio::test]
426    async fn test_send_invalid_json_response() {
427        let mut server = mockito::Server::new_async().await;
428        let mock = server
429            .mock("POST", "/")
430            .with_status(200)
431            .with_body("not valid json at all")
432            .create_async()
433            .await;
434
435        let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
436        let req = Request::get("https://api.example.com");
437        let result = client.send(req).await;
438
439        assert!(result.is_err());
440        assert!(result
441            .unwrap_err()
442            .to_string()
443            .contains("invalid sidecar response"));
444        mock.assert_async().await;
445    }
446
447    #[tokio::test]
448    async fn test_send_non_success_bridge_status() {
449        let mut server = mockito::Server::new_async().await;
450        let mock = server
451            .mock("POST", "/")
452            .with_status(200)
453            .with_header("content-type", "application/json")
454            .with_body(r#"{"status_code":429,"headers":{},"body":"rate limited","error":""}"#)
455            .create_async()
456            .await;
457
458        let client = GholaHttpClient::with_base_url(false, 4096, &server.url()).unwrap();
459        let req = Request::get("https://api.example.com");
460        let resp = client.send(req).await.unwrap();
461
462        assert_eq!(resp.status_code, 429);
463        assert!(!resp.is_success());
464        assert_eq!(resp.body, "rate limited");
465        mock.assert_async().await;
466    }
467
468    #[tokio::test]
469    async fn test_send_with_custom_headers() {
470        let mut server = mockito::Server::new_async().await;
471        let mock = server
472            .mock("POST", "/")
473            .with_status(200)
474            .with_header("content-type", "application/json")
475            .with_body(r#"{"status_code":200,"headers":{},"body":"{}","error":""}"#)
476            .create_async()
477            .await;
478
479        let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
480        let req = Request::get("https://api.example.com")
481            .with_header("Authorization", "Bearer token")
482            .with_header("X-Chain", "ethereum");
483        let resp = client.send(req).await.unwrap();
484
485        assert!(resp.is_success());
486        mock.assert_async().await;
487    }
488
489    #[tokio::test]
490    async fn test_send_connection_refused() {
491        let client = GholaHttpClient::with_base_url(true, 4096, "http://127.0.0.1:1").unwrap();
492        let req = Request::get("https://api.example.com");
493        let result = client.send(req).await;
494
495        assert!(result.is_err());
496        assert!(result
497            .unwrap_err()
498            .to_string()
499            .contains("failed to reach ghola sidecar"));
500    }
501
502    #[tokio::test]
503    async fn test_send_includes_buffer_size() {
504        let mut server = mockito::Server::new_async().await;
505        let mock = server
506            .mock("POST", "/")
507            .match_body(mockito::Matcher::PartialJsonString(
508                r#"{"buffer_size":8192}"#.to_string(),
509            ))
510            .with_status(200)
511            .with_header("content-type", "application/json")
512            .with_body(r#"{"status_code":200,"headers":{},"body":"ok","error":""}"#)
513            .create_async()
514            .await;
515
516        let client = GholaHttpClient::with_base_url(true, 8192, &server.url()).unwrap();
517        let req = Request::get("https://api.example.com");
518        let resp = client.send(req).await.unwrap();
519
520        assert_eq!(resp.status_code, 200);
521        mock.assert_async().await;
522    }
523
524    #[test]
525    fn test_custom_buffer_size_stored() {
526        let client = GholaHttpClient::new(true, 16384).unwrap();
527        assert_eq!(client.buffer_size, 16384);
528    }
529
530    #[test]
531    fn test_default_buffer_size() {
532        let client = GholaHttpClient::new(false, 4096).unwrap();
533        assert_eq!(client.buffer_size, 4096);
534    }
535}