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
22pub(crate) const 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
163pub(crate) fn 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
171/// What a failed `ghola --serve` spawn says, with where to get ghola.
172const GHOLA_SPAWN_FAILED: &str = "failed to spawn ghola --serve (install it with \
173     `go install github.com/robot-accomplice/ghola/cmd/ghola@latest` or from \
174     https://github.com/robot-accomplice/ghola/releases)";
175
176fn spawn_sidecar() -> Result<(), ScopeError> {
177    let child = Command::new("ghola")
178        .arg("--serve")
179        .stdout(std::process::Stdio::null())
180        .stderr(std::process::Stdio::piped())
181        .spawn()
182        .map_err(|e| ScopeError::io_context(e, GHOLA_SPAWN_FAILED))?;
183    SIDECAR_PID.store(child.id(), Ordering::Relaxed);
184    Ok(())
185}
186
187async fn wait_for_bridge(timeout: Duration) -> Result<(), ScopeError> {
188    let start = std::time::Instant::now();
189    while start.elapsed() < timeout {
190        if is_bridge_running() {
191            return Ok(());
192        }
193        tokio::time::sleep(POLL_INTERVAL).await;
194    }
195    Err(ScopeError::Network(format!(
196        "ghola sidecar did not become ready within {timeout:?}"
197    )))
198}
199
200// ============================================================================
201// Unit Tests
202// ============================================================================
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_bridge_request_serialization() {
210        let req = BridgeRequest {
211            url: "https://example.com".to_string(),
212            method: "GET".to_string(),
213            headers: HashMap::new(),
214            body: String::new(),
215            drift: true,
216            ghost: false,
217            retries: 3,
218            buffer_size: 4096,
219        };
220        let json = serde_json::to_string(&req).unwrap();
221        assert!(json.contains("\"drift\":true"));
222        assert!(json.contains("\"ghost\":false"));
223        assert!(json.contains("\"retries\":3"));
224        assert!(json.contains("\"buffer_size\":4096"));
225    }
226
227    #[test]
228    fn test_bridge_response_deserialization() {
229        let json = r#"{"status_code":200,"headers":{},"body":"ok","error":""}"#;
230        let resp: BridgeResponse = serde_json::from_str(json).unwrap();
231        assert_eq!(resp.status_code, 200);
232        assert_eq!(resp.body, "ok");
233        assert!(resp.error.is_empty());
234    }
235
236    #[test]
237    fn test_bridge_response_with_error() {
238        let json = r#"{"status_code":0,"headers":{},"body":"","error":"connection refused"}"#;
239        let resp: BridgeResponse = serde_json::from_str(json).unwrap();
240        assert_eq!(resp.error, "connection refused");
241    }
242
243    #[test]
244    fn test_bridge_response_missing_error_field() {
245        let json = r#"{"status_code":200,"headers":{},"body":"data"}"#;
246        let resp: BridgeResponse = serde_json::from_str(json).unwrap();
247        assert!(resp.error.is_empty());
248    }
249
250    #[test]
251    fn test_ghola_client_creation() {
252        let client = GholaHttpClient::new(true, 4096);
253        assert!(client.is_ok());
254    }
255
256    #[test]
257    fn test_ghola_client_creation_stealth_off() {
258        let client = GholaHttpClient::new(false, 4096);
259        assert!(client.is_ok());
260    }
261
262    #[test]
263    fn test_sidecar_pid_default_zero() {
264        assert_eq!(SIDECAR_PID.load(Ordering::Relaxed), 0);
265    }
266
267    #[test]
268    fn test_is_bridge_running_returns_bool() {
269        // Smoke test: the result depends on the host; the probe must not panic.
270        let _ = is_bridge_running();
271    }
272
273    #[test]
274    fn test_bridge_request_full_serialization() {
275        let mut headers = HashMap::new();
276        headers.insert("Authorization".to_string(), "Bearer tk".to_string());
277        let req = BridgeRequest {
278            url: "https://api.test.com/v1".to_string(),
279            method: "POST".to_string(),
280            headers,
281            body: r#"{"data":1}"#.to_string(),
282            drift: false,
283            ghost: true,
284            retries: 0,
285            buffer_size: 8192,
286        };
287        let json = serde_json::to_string(&req).unwrap();
288        assert!(json.contains("\"method\":\"POST\""));
289        assert!(json.contains("\"ghost\":true"));
290        assert!(json.contains("\"drift\":false"));
291        assert!(json.contains("\"retries\":0"));
292        assert!(json.contains("\"buffer_size\":8192"));
293        assert!(json.contains("Authorization"));
294    }
295
296    #[test]
297    fn test_bridge_response_roundtrip() {
298        let mut headers = HashMap::new();
299        headers.insert("content-type".to_string(), "application/json".to_string());
300        let json = serde_json::json!({
301            "status_code": 201,
302            "headers": headers,
303            "body": r#"{"id":42}"#,
304            "error": ""
305        });
306        let resp: BridgeResponse = serde_json::from_value(json).unwrap();
307        assert_eq!(resp.status_code, 201);
308        assert_eq!(resp.body, r#"{"id":42}"#);
309        assert!(resp.error.is_empty());
310        assert_eq!(
311            resp.headers.get("content-type").map(String::as_str),
312            Some("application/json")
313        );
314    }
315
316    #[tokio::test]
317    async fn test_wait_for_bridge_completes() {
318        let result = wait_for_bridge(Duration::from_millis(200)).await;
319        // Either succeeds (sidecar running) or fails with timeout
320        match result {
321            Ok(()) => {} // sidecar was already running
322            Err(e) => assert!(e.to_string().contains("did not become ready")),
323        }
324    }
325
326    #[tokio::test]
327    async fn test_send_to_mock_sidecar() {
328        let mut server = mockito::Server::new_async().await;
329        let mock = server
330            .mock("POST", "/")
331            .with_status(200)
332            .with_header("content-type", "application/json")
333            .with_body(
334                r#"{"status_code":200,"headers":{},"body":"{\"result\":\"ok\"}","error":""}"#,
335            )
336            .create_async()
337            .await;
338
339        let ghola = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
340
341        let req = Request::get("https://api.example.com/data");
342        let resp = ghola.send(req).await.unwrap();
343        assert_eq!(resp.status_code, 200);
344        assert_eq!(resp.body, r#"{"result":"ok"}"#);
345        mock.assert_async().await;
346    }
347
348    #[test]
349    fn test_ghola_in_path_returns_bool() {
350        // Smoke test: the result depends on the host; the probe must not panic.
351        let _ = ghola_in_path();
352    }
353
354    #[test]
355    fn test_sidecar_constants() {
356        assert_eq!(SIDECAR_ADDR, "127.0.0.1:18789");
357        assert_eq!(SIDECAR_URL, "http://127.0.0.1:18789");
358        assert_eq!(SPAWN_TIMEOUT, Duration::from_secs(5));
359        assert_eq!(POLL_INTERVAL, Duration::from_millis(100));
360    }
361
362    #[tokio::test]
363    async fn test_send_success_via_mock() {
364        let mut server = mockito::Server::new_async().await;
365        let mock = server
366            .mock("POST", "/")
367            .with_status(200)
368            .with_header("content-type", "application/json")
369            .with_body(r#"{"status_code":200,"headers":{"x-test":"yes"},"body":"{\"data\":42}","error":""}"#)
370            .create_async()
371            .await;
372
373        let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
374        let req = Request::get("https://api.example.com/v1");
375        let resp = client.send(req).await.unwrap();
376
377        assert_eq!(resp.status_code, 200);
378        assert!(resp.is_success());
379        assert_eq!(resp.body, r#"{"data":42}"#);
380        assert_eq!(resp.headers.get("x-test").map(String::as_str), Some("yes"));
381        mock.assert_async().await;
382    }
383
384    #[tokio::test]
385    async fn test_send_with_stealth_off() {
386        let mut server = mockito::Server::new_async().await;
387        let mock = server
388            .mock("POST", "/")
389            .with_status(200)
390            .with_header("content-type", "application/json")
391            .with_body(r#"{"status_code":200,"headers":{},"body":"ok","error":""}"#)
392            .create_async()
393            .await;
394
395        let client = GholaHttpClient::with_base_url(false, 4096, &server.url()).unwrap();
396        let req = Request::post_json("https://api.example.com", r#"{"q":1}"#);
397        let resp = client.send(req).await.unwrap();
398
399        assert_eq!(resp.status_code, 200);
400        assert_eq!(resp.body, "ok");
401        mock.assert_async().await;
402    }
403
404    #[tokio::test]
405    async fn test_send_bridge_error_response() {
406        let mut server = mockito::Server::new_async().await;
407        let mock = server
408            .mock("POST", "/")
409            .with_status(200)
410            .with_header("content-type", "application/json")
411            .with_body(r#"{"status_code":0,"headers":{},"body":"","error":"upstream timeout"}"#)
412            .create_async()
413            .await;
414
415        let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
416        let req = Request::get("https://api.example.com");
417        let result = client.send(req).await;
418
419        assert!(result.is_err());
420        assert!(result.unwrap_err().to_string().contains("upstream timeout"));
421        mock.assert_async().await;
422    }
423
424    #[tokio::test]
425    async fn test_send_invalid_json_response() {
426        let mut server = mockito::Server::new_async().await;
427        let mock = server
428            .mock("POST", "/")
429            .with_status(200)
430            .with_body("not valid json at all")
431            .create_async()
432            .await;
433
434        let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
435        let req = Request::get("https://api.example.com");
436        let result = client.send(req).await;
437
438        assert!(result.is_err());
439        assert!(
440            result
441                .unwrap_err()
442                .to_string()
443                .contains("invalid sidecar response")
444        );
445        mock.assert_async().await;
446    }
447
448    #[tokio::test]
449    async fn test_send_non_success_bridge_status() {
450        let mut server = mockito::Server::new_async().await;
451        let mock = server
452            .mock("POST", "/")
453            .with_status(200)
454            .with_header("content-type", "application/json")
455            .with_body(r#"{"status_code":429,"headers":{},"body":"rate limited","error":""}"#)
456            .create_async()
457            .await;
458
459        let client = GholaHttpClient::with_base_url(false, 4096, &server.url()).unwrap();
460        let req = Request::get("https://api.example.com");
461        let resp = client.send(req).await.unwrap();
462
463        assert_eq!(resp.status_code, 429);
464        assert!(!resp.is_success());
465        assert_eq!(resp.body, "rate limited");
466        mock.assert_async().await;
467    }
468
469    #[tokio::test]
470    async fn test_send_with_custom_headers() {
471        let mut server = mockito::Server::new_async().await;
472        let mock = server
473            .mock("POST", "/")
474            .with_status(200)
475            .with_header("content-type", "application/json")
476            .with_body(r#"{"status_code":200,"headers":{},"body":"{}","error":""}"#)
477            .create_async()
478            .await;
479
480        let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
481        let req = Request::get("https://api.example.com")
482            .with_header("Authorization", "Bearer token")
483            .with_header("X-Chain", "ethereum");
484        let resp = client.send(req).await.unwrap();
485
486        assert!(resp.is_success());
487        mock.assert_async().await;
488    }
489
490    #[tokio::test]
491    async fn test_send_connection_refused() {
492        let client = GholaHttpClient::with_base_url(true, 4096, "http://127.0.0.1:1").unwrap();
493        let req = Request::get("https://api.example.com");
494        let result = client.send(req).await;
495
496        assert!(result.is_err());
497        assert!(
498            result
499                .unwrap_err()
500                .to_string()
501                .contains("failed to reach ghola sidecar")
502        );
503    }
504
505    #[tokio::test]
506    async fn test_send_includes_buffer_size() {
507        let mut server = mockito::Server::new_async().await;
508        let mock = server
509            .mock("POST", "/")
510            .match_body(mockito::Matcher::PartialJsonString(
511                r#"{"buffer_size":8192}"#.to_string(),
512            ))
513            .with_status(200)
514            .with_header("content-type", "application/json")
515            .with_body(r#"{"status_code":200,"headers":{},"body":"ok","error":""}"#)
516            .create_async()
517            .await;
518
519        let client = GholaHttpClient::with_base_url(true, 8192, &server.url()).unwrap();
520        let req = Request::get("https://api.example.com");
521        let resp = client.send(req).await.unwrap();
522
523        assert_eq!(resp.status_code, 200);
524        mock.assert_async().await;
525    }
526
527    #[test]
528    fn test_custom_buffer_size_stored() {
529        let client = GholaHttpClient::new(true, 16384).unwrap();
530        assert_eq!(client.buffer_size, 16384);
531    }
532
533    #[test]
534    fn test_default_buffer_size() {
535        let client = GholaHttpClient::new(false, 4096).unwrap();
536        assert_eq!(client.buffer_size, 4096);
537    }
538}