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. When nothing answers on that
9//! address, `http::shared::build` starts it with [`start_sidecar`]
10//! (`<binary> --serve`, the binary found by `ghola_install::find`). The
11//! sidecar it started is kept here until [`stop_spawned_sidecar`] stops it;
12//! the caller decides when (the CLI asks at exit).
13
14use super::{HttpClient, Request, Response};
15use crate::error::ScopeError;
16use async_trait::async_trait;
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19use std::net::TcpStream;
20use std::path::Path;
21use std::process::{Child, Command, Stdio};
22use std::sync::Mutex;
23use std::time::{Duration, Instant};
24
25pub(crate) const SIDECAR_ADDR: &str = "127.0.0.1:18789";
26const SIDECAR_URL: &str = "http://127.0.0.1:18789";
27/// How long a started sidecar has to answer.
28pub(crate) const SPAWN_TIMEOUT: Duration = Duration::from_secs(5);
29const POLL_INTERVAL: Duration = Duration::from_millis(100);
30
31/// The sidecar this process started and has not stopped.
32static SPAWNED: Mutex<Option<Child>> = Mutex::new(None);
33
34#[derive(Serialize)]
35struct BridgeRequest {
36    url: String,
37    method: String,
38    headers: HashMap<String, String>,
39    body: String,
40    drift: bool,
41    ghost: bool,
42    retries: i32,
43    buffer_size: u32,
44}
45
46#[derive(Deserialize)]
47struct BridgeResponse {
48    status_code: u16,
49    headers: HashMap<String, String>,
50    body: String,
51    #[serde(default)]
52    error: String,
53}
54
55/// HTTP client that forwards requests to the Ghola sidecar bridge
56/// running on `127.0.0.1:18789`. When `stealth` is `true`, the bridge
57/// applies temporal drift and ghost signing to every request.
58pub struct GholaHttpClient {
59    client: reqwest::Client,
60    stealth: bool,
61    buffer_size: u32,
62    base_url: String,
63}
64
65impl GholaHttpClient {
66    /// Creates a new client that talks to an already-running sidecar.
67    pub fn new(stealth: bool, buffer_size: u32) -> Result<Self, ScopeError> {
68        let client = reqwest::Client::builder()
69            .timeout(Duration::from_secs(30))
70            .build()
71            .map_err(|e| {
72                ScopeError::Network(format!("failed to build ghola bridge client: {e}"))
73            })?;
74        Ok(Self {
75            client,
76            stealth,
77            buffer_size,
78            base_url: SIDECAR_URL.to_string(),
79        })
80    }
81
82    /// Creates a client pointing at a custom URL (for testing).
83    #[cfg(test)]
84    pub fn with_base_url(
85        stealth: bool,
86        buffer_size: u32,
87        base_url: &str,
88    ) -> Result<Self, ScopeError> {
89        let client = reqwest::Client::builder()
90            .timeout(Duration::from_secs(5))
91            .build()
92            .map_err(|e| {
93                ScopeError::Network(format!("failed to build ghola bridge client: {e}"))
94            })?;
95        Ok(Self {
96            client,
97            stealth,
98            buffer_size,
99            base_url: base_url.to_string(),
100        })
101    }
102}
103
104#[async_trait]
105impl HttpClient for GholaHttpClient {
106    async fn send(&self, request: Request) -> Result<Response, ScopeError> {
107        let bridge_req = BridgeRequest {
108            url: request.url,
109            method: request.method,
110            headers: request.headers,
111            body: request.body.unwrap_or_default(),
112            drift: self.stealth,
113            ghost: self.stealth,
114            retries: 0,
115            buffer_size: self.buffer_size,
116        };
117
118        let resp = self
119            .client
120            .post(&self.base_url)
121            .json(&bridge_req)
122            .send()
123            .await
124            .map_err(|e| ScopeError::Network(format!("failed to reach ghola sidecar: {e}")))?;
125
126        let bridge_resp: BridgeResponse = resp
127            .json()
128            .await
129            .map_err(|e| ScopeError::Network(format!("invalid sidecar response: {e}")))?;
130
131        if !bridge_resp.error.is_empty() {
132            return Err(ScopeError::Network(format!(
133                "sidecar error: {}",
134                bridge_resp.error
135            )));
136        }
137
138        Ok(Response {
139            status_code: bridge_resp.status_code,
140            headers: bridge_resp.headers,
141            body: bridge_resp.body,
142        })
143    }
144}
145
146pub(crate) fn is_bridge_running() -> bool {
147    SIDECAR_ADDR
148        .parse()
149        .ok()
150        .and_then(|addr| TcpStream::connect_timeout(&addr, Duration::from_millis(200)).ok())
151        .is_some()
152}
153
154fn spawned() -> std::sync::MutexGuard<'static, Option<Child>> {
155    SPAWNED.lock().unwrap_or_else(|e| e.into_inner())
156}
157
158/// Starts `binary --serve` and waits up to `timeout` for the sidecar to
159/// answer on the sidecar's address (`127.0.0.1:18789`). Returns its PID. The sidecar is kept until
160/// [`stop_spawned_sidecar`]; it is not stopped when this process exits.
161///
162/// It blocks the calling thread while it waits.
163///
164/// # Errors
165///
166/// Returns `IoError` when the binary cannot run; `Network` when it exits
167/// or does not answer in time (it is stopped then).
168pub fn start_sidecar(binary: &Path, timeout: Duration) -> Result<u32, ScopeError> {
169    let mut child = Command::new(binary)
170        .arg("--serve")
171        .stdin(Stdio::null())
172        .stdout(Stdio::null())
173        .stderr(Stdio::null())
174        .spawn()
175        .map_err(|e| {
176            ScopeError::io_context(e, format!("failed to start {} --serve", binary.display()))
177        })?;
178    let start = Instant::now();
179    loop {
180        if is_bridge_running() {
181            let pid = child.id();
182            *spawned() = Some(child);
183            return Ok(pid);
184        }
185        if let Ok(Some(status)) = child.try_wait() {
186            return Err(ScopeError::Network(format!(
187                "{} --serve exited ({status}) before it answered on {SIDECAR_ADDR}",
188                binary.display()
189            )));
190        }
191        if start.elapsed() >= timeout {
192            let _ = child.kill();
193            let _ = child.wait();
194            return Err(ScopeError::Network(format!(
195                "the ghola sidecar did not answer on {SIDECAR_ADDR} within {timeout:?}"
196            )));
197        }
198        std::thread::sleep(POLL_INTERVAL);
199    }
200}
201
202/// The PID of the sidecar this process started, while it runs.
203pub fn spawned_sidecar() -> Option<u32> {
204    let mut guard = spawned();
205    match guard.as_mut().map(Child::try_wait) {
206        Some(Ok(None)) => guard.as_ref().map(Child::id),
207        _ => {
208            *guard = None;
209            None
210        }
211    }
212}
213
214/// Stops the sidecar this process started. Returns its PID, or `None`
215/// when this process started none.
216pub fn stop_spawned_sidecar() -> Option<u32> {
217    let mut child = spawned().take()?;
218    let _ = child.kill();
219    let _ = child.wait();
220    Some(child.id())
221}
222
223/// Keeps `child` as the sidecar this process started, so tests elsewhere
224/// (the CLI's exit question) can stop it without a sidecar port.
225#[cfg(any(test, feature = "test-support"))]
226pub fn adopt_sidecar(child: Child) {
227    *spawned() = Some(child);
228}
229
230// ============================================================================
231// Unit Tests
232// ============================================================================
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn test_bridge_request_serialization() {
240        let req = BridgeRequest {
241            url: "https://example.com".to_string(),
242            method: "GET".to_string(),
243            headers: HashMap::new(),
244            body: String::new(),
245            drift: true,
246            ghost: false,
247            retries: 3,
248            buffer_size: 4096,
249        };
250        let json = serde_json::to_string(&req).unwrap();
251        assert!(json.contains("\"drift\":true"));
252        assert!(json.contains("\"ghost\":false"));
253        assert!(json.contains("\"retries\":3"));
254        assert!(json.contains("\"buffer_size\":4096"));
255    }
256
257    #[test]
258    fn test_bridge_response_deserialization() {
259        let json = r#"{"status_code":200,"headers":{},"body":"ok","error":""}"#;
260        let resp: BridgeResponse = serde_json::from_str(json).unwrap();
261        assert_eq!(resp.status_code, 200);
262        assert_eq!(resp.body, "ok");
263        assert!(resp.error.is_empty());
264    }
265
266    #[test]
267    fn test_bridge_response_with_error() {
268        let json = r#"{"status_code":0,"headers":{},"body":"","error":"connection refused"}"#;
269        let resp: BridgeResponse = serde_json::from_str(json).unwrap();
270        assert_eq!(resp.error, "connection refused");
271    }
272
273    #[test]
274    fn test_bridge_response_missing_error_field() {
275        let json = r#"{"status_code":200,"headers":{},"body":"data"}"#;
276        let resp: BridgeResponse = serde_json::from_str(json).unwrap();
277        assert!(resp.error.is_empty());
278    }
279
280    #[test]
281    fn test_ghola_client_creation() {
282        let client = GholaHttpClient::new(true, 4096);
283        assert!(client.is_ok());
284    }
285
286    #[test]
287    fn test_ghola_client_creation_stealth_off() {
288        let client = GholaHttpClient::new(false, 4096);
289        assert!(client.is_ok());
290    }
291
292    /// Serializes the tests that use the spawned sidecar or its port.
293    static SIDECAR: Mutex<()> = Mutex::new(());
294
295    fn sidecar_lock() -> std::sync::MutexGuard<'static, ()> {
296        SIDECAR.lock().unwrap_or_else(|e| e.into_inner())
297    }
298
299    #[test]
300    fn test_no_sidecar_is_spawned_by_default() {
301        let _lock = sidecar_lock();
302        assert_eq!(spawned_sidecar(), None);
303        assert_eq!(stop_spawned_sidecar(), None);
304    }
305
306    #[test]
307    fn test_start_sidecar_reports_a_missing_binary() {
308        let _lock = sidecar_lock();
309        let dir = tempfile::tempdir().unwrap();
310        let err =
311            start_sidecar(&dir.path().join("no-ghola"), Duration::from_millis(50)).unwrap_err();
312        assert!(err.to_string().contains("--serve"), "{err}");
313        assert_eq!(spawned_sidecar(), None);
314    }
315
316    /// A process that runs until it is stopped.
317    fn long_running() -> Child {
318        let mut command = if cfg!(windows) {
319            let mut c = Command::new("ping");
320            c.args(["-n", "60", "127.0.0.1"]);
321            c
322        } else {
323            let mut c = Command::new("sleep");
324            c.arg("60");
325            c
326        };
327        command
328            .stdin(Stdio::null())
329            .stdout(Stdio::null())
330            .stderr(Stdio::null())
331            .spawn()
332            .unwrap()
333    }
334
335    #[test]
336    fn test_an_adopted_sidecar_is_kept_until_stopped() {
337        let _lock = sidecar_lock();
338        let child = long_running();
339        let pid = child.id();
340        adopt_sidecar(child);
341        assert_eq!(spawned_sidecar(), Some(pid));
342        assert_eq!(stop_spawned_sidecar(), Some(pid));
343        assert_eq!(spawned_sidecar(), None);
344        assert_eq!(stop_spawned_sidecar(), None);
345    }
346
347    #[test]
348    fn test_a_sidecar_that_exited_is_forgotten() {
349        let _lock = sidecar_lock();
350        let mut child = long_running();
351        child.kill().unwrap();
352        child.wait().unwrap();
353        adopt_sidecar(child);
354        assert_eq!(spawned_sidecar(), None);
355        assert_eq!(stop_spawned_sidecar(), None);
356    }
357
358    /// An executable `sh` script that stands in for `ghola --serve`.
359    #[cfg(unix)]
360    fn fake_ghola(dir: &Path, body: &str) -> std::path::PathBuf {
361        use std::os::unix::fs::PermissionsExt;
362        let path = dir.join("ghola");
363        std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
364        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
365        path
366    }
367
368    #[cfg(unix)]
369    #[test]
370    fn test_start_sidecar_reports_a_sidecar_that_exits() {
371        let _lock = sidecar_lock();
372        if is_bridge_running() {
373            eprintln!("skipped: something answers on {SIDECAR_ADDR}");
374            return;
375        }
376        let dir = tempfile::tempdir().unwrap();
377        let ghola = fake_ghola(dir.path(), "[ \"$1\" = --serve ] && exit 3");
378        let err = start_sidecar(&ghola, Duration::from_secs(10)).unwrap_err();
379        assert!(err.to_string().contains("exited"), "{err}");
380        assert_eq!(spawned_sidecar(), None);
381    }
382
383    #[cfg(unix)]
384    #[test]
385    fn test_start_sidecar_stops_a_sidecar_that_never_answers() {
386        let _lock = sidecar_lock();
387        if is_bridge_running() {
388            eprintln!("skipped: something answers on {SIDECAR_ADDR}");
389            return;
390        }
391        let dir = tempfile::tempdir().unwrap();
392        let ghola = fake_ghola(dir.path(), "exec sleep 60");
393        let started = Instant::now();
394        let err = start_sidecar(&ghola, Duration::from_millis(300)).unwrap_err();
395        assert!(err.to_string().contains("did not answer"), "{err}");
396        assert!(started.elapsed() < Duration::from_secs(5));
397        assert_eq!(spawned_sidecar(), None);
398    }
399
400    #[cfg(unix)]
401    #[test]
402    fn test_start_sidecar_keeps_a_sidecar_that_answers() {
403        let _lock = sidecar_lock();
404        // The sidecar's port, held here: the fake answers through it.
405        let Ok(_port) = std::net::TcpListener::bind(SIDECAR_ADDR) else {
406            eprintln!("skipped: {SIDECAR_ADDR} is in use");
407            return;
408        };
409        let dir = tempfile::tempdir().unwrap();
410        let ghola = fake_ghola(dir.path(), "exec sleep 60");
411        let pid = start_sidecar(&ghola, Duration::from_secs(5)).unwrap();
412        assert_eq!(spawned_sidecar(), Some(pid));
413        assert_eq!(stop_spawned_sidecar(), Some(pid));
414        assert_eq!(spawned_sidecar(), None);
415    }
416
417    #[test]
418    fn test_is_bridge_running_returns_bool() {
419        // Smoke test: the result depends on the host; the probe must not panic.
420        let _ = is_bridge_running();
421    }
422
423    #[test]
424    fn test_bridge_request_full_serialization() {
425        let mut headers = HashMap::new();
426        headers.insert("Authorization".to_string(), "Bearer tk".to_string());
427        let req = BridgeRequest {
428            url: "https://api.test.com/v1".to_string(),
429            method: "POST".to_string(),
430            headers,
431            body: r#"{"data":1}"#.to_string(),
432            drift: false,
433            ghost: true,
434            retries: 0,
435            buffer_size: 8192,
436        };
437        let json = serde_json::to_string(&req).unwrap();
438        assert!(json.contains("\"method\":\"POST\""));
439        assert!(json.contains("\"ghost\":true"));
440        assert!(json.contains("\"drift\":false"));
441        assert!(json.contains("\"retries\":0"));
442        assert!(json.contains("\"buffer_size\":8192"));
443        assert!(json.contains("Authorization"));
444    }
445
446    #[test]
447    fn test_bridge_response_roundtrip() {
448        let mut headers = HashMap::new();
449        headers.insert("content-type".to_string(), "application/json".to_string());
450        let json = serde_json::json!({
451            "status_code": 201,
452            "headers": headers,
453            "body": r#"{"id":42}"#,
454            "error": ""
455        });
456        let resp: BridgeResponse = serde_json::from_value(json).unwrap();
457        assert_eq!(resp.status_code, 201);
458        assert_eq!(resp.body, r#"{"id":42}"#);
459        assert!(resp.error.is_empty());
460        assert_eq!(
461            resp.headers.get("content-type").map(String::as_str),
462            Some("application/json")
463        );
464    }
465
466    #[tokio::test]
467    async fn test_send_to_mock_sidecar() {
468        let mut server = mockito::Server::new_async().await;
469        let mock = server
470            .mock("POST", "/")
471            .with_status(200)
472            .with_header("content-type", "application/json")
473            .with_body(
474                r#"{"status_code":200,"headers":{},"body":"{\"result\":\"ok\"}","error":""}"#,
475            )
476            .create_async()
477            .await;
478
479        let ghola = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
480
481        let req = Request::get("https://api.example.com/data");
482        let resp = ghola.send(req).await.unwrap();
483        assert_eq!(resp.status_code, 200);
484        assert_eq!(resp.body, r#"{"result":"ok"}"#);
485        mock.assert_async().await;
486    }
487
488    #[test]
489    fn test_sidecar_constants() {
490        assert_eq!(SIDECAR_ADDR, "127.0.0.1:18789");
491        assert_eq!(SIDECAR_URL, "http://127.0.0.1:18789");
492        assert_eq!(SPAWN_TIMEOUT, Duration::from_secs(5));
493        assert_eq!(POLL_INTERVAL, Duration::from_millis(100));
494    }
495
496    #[tokio::test]
497    async fn test_send_success_via_mock() {
498        let mut server = mockito::Server::new_async().await;
499        let mock = server
500            .mock("POST", "/")
501            .with_status(200)
502            .with_header("content-type", "application/json")
503            .with_body(r#"{"status_code":200,"headers":{"x-test":"yes"},"body":"{\"data\":42}","error":""}"#)
504            .create_async()
505            .await;
506
507        let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
508        let req = Request::get("https://api.example.com/v1");
509        let resp = client.send(req).await.unwrap();
510
511        assert_eq!(resp.status_code, 200);
512        assert!(resp.is_success());
513        assert_eq!(resp.body, r#"{"data":42}"#);
514        assert_eq!(resp.headers.get("x-test").map(String::as_str), Some("yes"));
515        mock.assert_async().await;
516    }
517
518    #[tokio::test]
519    async fn test_send_with_stealth_off() {
520        let mut server = mockito::Server::new_async().await;
521        let mock = server
522            .mock("POST", "/")
523            .with_status(200)
524            .with_header("content-type", "application/json")
525            .with_body(r#"{"status_code":200,"headers":{},"body":"ok","error":""}"#)
526            .create_async()
527            .await;
528
529        let client = GholaHttpClient::with_base_url(false, 4096, &server.url()).unwrap();
530        let req = Request::post_json("https://api.example.com", r#"{"q":1}"#);
531        let resp = client.send(req).await.unwrap();
532
533        assert_eq!(resp.status_code, 200);
534        assert_eq!(resp.body, "ok");
535        mock.assert_async().await;
536    }
537
538    #[tokio::test]
539    async fn test_send_bridge_error_response() {
540        let mut server = mockito::Server::new_async().await;
541        let mock = server
542            .mock("POST", "/")
543            .with_status(200)
544            .with_header("content-type", "application/json")
545            .with_body(r#"{"status_code":0,"headers":{},"body":"","error":"upstream timeout"}"#)
546            .create_async()
547            .await;
548
549        let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
550        let req = Request::get("https://api.example.com");
551        let result = client.send(req).await;
552
553        assert!(result.is_err());
554        assert!(result.unwrap_err().to_string().contains("upstream timeout"));
555        mock.assert_async().await;
556    }
557
558    #[tokio::test]
559    async fn test_send_invalid_json_response() {
560        let mut server = mockito::Server::new_async().await;
561        let mock = server
562            .mock("POST", "/")
563            .with_status(200)
564            .with_body("not valid json at all")
565            .create_async()
566            .await;
567
568        let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
569        let req = Request::get("https://api.example.com");
570        let result = client.send(req).await;
571
572        assert!(result.is_err());
573        assert!(
574            result
575                .unwrap_err()
576                .to_string()
577                .contains("invalid sidecar response")
578        );
579        mock.assert_async().await;
580    }
581
582    #[tokio::test]
583    async fn test_send_non_success_bridge_status() {
584        let mut server = mockito::Server::new_async().await;
585        let mock = server
586            .mock("POST", "/")
587            .with_status(200)
588            .with_header("content-type", "application/json")
589            .with_body(r#"{"status_code":429,"headers":{},"body":"rate limited","error":""}"#)
590            .create_async()
591            .await;
592
593        let client = GholaHttpClient::with_base_url(false, 4096, &server.url()).unwrap();
594        let req = Request::get("https://api.example.com");
595        let resp = client.send(req).await.unwrap();
596
597        assert_eq!(resp.status_code, 429);
598        assert!(!resp.is_success());
599        assert_eq!(resp.body, "rate limited");
600        mock.assert_async().await;
601    }
602
603    #[tokio::test]
604    async fn test_send_with_custom_headers() {
605        let mut server = mockito::Server::new_async().await;
606        let mock = server
607            .mock("POST", "/")
608            .with_status(200)
609            .with_header("content-type", "application/json")
610            .with_body(r#"{"status_code":200,"headers":{},"body":"{}","error":""}"#)
611            .create_async()
612            .await;
613
614        let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
615        let req = Request::get("https://api.example.com")
616            .with_header("Authorization", "Bearer token")
617            .with_header("X-Chain", "ethereum");
618        let resp = client.send(req).await.unwrap();
619
620        assert!(resp.is_success());
621        mock.assert_async().await;
622    }
623
624    #[tokio::test]
625    async fn test_send_connection_refused() {
626        let client = GholaHttpClient::with_base_url(true, 4096, "http://127.0.0.1:1").unwrap();
627        let req = Request::get("https://api.example.com");
628        let result = client.send(req).await;
629
630        assert!(result.is_err());
631        assert!(
632            result
633                .unwrap_err()
634                .to_string()
635                .contains("failed to reach ghola sidecar")
636        );
637    }
638
639    #[tokio::test]
640    async fn test_send_includes_buffer_size() {
641        let mut server = mockito::Server::new_async().await;
642        let mock = server
643            .mock("POST", "/")
644            .match_body(mockito::Matcher::PartialJsonString(
645                r#"{"buffer_size":8192}"#.to_string(),
646            ))
647            .with_status(200)
648            .with_header("content-type", "application/json")
649            .with_body(r#"{"status_code":200,"headers":{},"body":"ok","error":""}"#)
650            .create_async()
651            .await;
652
653        let client = GholaHttpClient::with_base_url(true, 8192, &server.url()).unwrap();
654        let req = Request::get("https://api.example.com");
655        let resp = client.send(req).await.unwrap();
656
657        assert_eq!(resp.status_code, 200);
658        mock.assert_async().await;
659    }
660
661    #[test]
662    fn test_custom_buffer_size_stored() {
663        let client = GholaHttpClient::new(true, 16384).unwrap();
664        assert_eq!(client.buffer_size, 16384);
665    }
666
667    #[test]
668    fn test_default_buffer_size() {
669        let client = GholaHttpClient::new(false, 4096).unwrap();
670        assert_eq!(client.buffer_size, 4096);
671    }
672}