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