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    /// [`start_sidecar`], retried while Linux says the fake script is
359    /// busy: another test thread that forks while the script is still open
360    /// for writing holds it (ETXTBSY) for a moment.
361    #[cfg(unix)]
362    fn start_retrying(ghola: &Path, timeout: Duration) -> Result<u32, ScopeError> {
363        for _ in 0..50 {
364            match start_sidecar(ghola, timeout) {
365                Err(e) if e.to_string().contains("Text file busy") => {
366                    std::thread::sleep(Duration::from_millis(20));
367                }
368                other => return other,
369            }
370        }
371        start_sidecar(ghola, timeout)
372    }
373
374    /// An executable `sh` script that stands in for `ghola --serve`.
375    #[cfg(unix)]
376    fn fake_ghola(dir: &Path, body: &str) -> std::path::PathBuf {
377        use std::os::unix::fs::PermissionsExt;
378        let path = dir.join("ghola");
379        std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
380        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
381        path
382    }
383
384    #[cfg(unix)]
385    #[test]
386    fn test_start_sidecar_reports_a_sidecar_that_exits() {
387        let _lock = sidecar_lock();
388        if is_bridge_running() {
389            eprintln!("skipped: something answers on {SIDECAR_ADDR}");
390            return;
391        }
392        let dir = tempfile::tempdir().unwrap();
393        let ghola = fake_ghola(dir.path(), "[ \"$1\" = --serve ] && exit 3");
394        let err = start_retrying(&ghola, Duration::from_secs(10)).unwrap_err();
395        assert!(err.to_string().contains("exited"), "{err}");
396        assert_eq!(spawned_sidecar(), None);
397    }
398
399    #[cfg(unix)]
400    #[test]
401    fn test_start_sidecar_stops_a_sidecar_that_never_answers() {
402        let _lock = sidecar_lock();
403        if is_bridge_running() {
404            eprintln!("skipped: something answers on {SIDECAR_ADDR}");
405            return;
406        }
407        let dir = tempfile::tempdir().unwrap();
408        let ghola = fake_ghola(dir.path(), "exec sleep 60");
409        let started = Instant::now();
410        let err = start_retrying(&ghola, Duration::from_millis(300)).unwrap_err();
411        assert!(err.to_string().contains("did not answer"), "{err}");
412        assert!(started.elapsed() < Duration::from_secs(5));
413        assert_eq!(spawned_sidecar(), None);
414    }
415
416    #[cfg(unix)]
417    #[test]
418    fn test_start_sidecar_keeps_a_sidecar_that_answers() {
419        let _lock = sidecar_lock();
420        // The sidecar's port, held here: the fake answers through it.
421        let Ok(_port) = std::net::TcpListener::bind(SIDECAR_ADDR) else {
422            eprintln!("skipped: {SIDECAR_ADDR} is in use");
423            return;
424        };
425        let dir = tempfile::tempdir().unwrap();
426        let ghola = fake_ghola(dir.path(), "exec sleep 60");
427        let pid = start_retrying(&ghola, Duration::from_secs(5)).unwrap();
428        assert_eq!(spawned_sidecar(), Some(pid));
429        assert_eq!(stop_spawned_sidecar(), Some(pid));
430        assert_eq!(spawned_sidecar(), None);
431    }
432
433    #[test]
434    fn test_is_bridge_running_returns_bool() {
435        // Smoke test: the result depends on the host; the probe must not panic.
436        let _ = is_bridge_running();
437    }
438
439    #[test]
440    fn test_bridge_request_full_serialization() {
441        let mut headers = HashMap::new();
442        headers.insert("Authorization".to_string(), "Bearer tk".to_string());
443        let req = BridgeRequest {
444            url: "https://api.test.com/v1".to_string(),
445            method: "POST".to_string(),
446            headers,
447            body: r#"{"data":1}"#.to_string(),
448            drift: false,
449            ghost: true,
450            retries: 0,
451            buffer_size: 8192,
452        };
453        let json = serde_json::to_string(&req).unwrap();
454        assert!(json.contains("\"method\":\"POST\""));
455        assert!(json.contains("\"ghost\":true"));
456        assert!(json.contains("\"drift\":false"));
457        assert!(json.contains("\"retries\":0"));
458        assert!(json.contains("\"buffer_size\":8192"));
459        assert!(json.contains("Authorization"));
460    }
461
462    #[test]
463    fn test_bridge_response_roundtrip() {
464        let mut headers = HashMap::new();
465        headers.insert("content-type".to_string(), "application/json".to_string());
466        let json = serde_json::json!({
467            "status_code": 201,
468            "headers": headers,
469            "body": r#"{"id":42}"#,
470            "error": ""
471        });
472        let resp: BridgeResponse = serde_json::from_value(json).unwrap();
473        assert_eq!(resp.status_code, 201);
474        assert_eq!(resp.body, r#"{"id":42}"#);
475        assert!(resp.error.is_empty());
476        assert_eq!(
477            resp.headers.get("content-type").map(String::as_str),
478            Some("application/json")
479        );
480    }
481
482    #[tokio::test]
483    async fn test_send_to_mock_sidecar() {
484        let mut server = mockito::Server::new_async().await;
485        let mock = server
486            .mock("POST", "/")
487            .with_status(200)
488            .with_header("content-type", "application/json")
489            .with_body(
490                r#"{"status_code":200,"headers":{},"body":"{\"result\":\"ok\"}","error":""}"#,
491            )
492            .create_async()
493            .await;
494
495        let ghola = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
496
497        let req = Request::get("https://api.example.com/data");
498        let resp = ghola.send(req).await.unwrap();
499        assert_eq!(resp.status_code, 200);
500        assert_eq!(resp.body, r#"{"result":"ok"}"#);
501        mock.assert_async().await;
502    }
503
504    #[test]
505    fn test_sidecar_constants() {
506        assert_eq!(SIDECAR_ADDR, "127.0.0.1:18789");
507        assert_eq!(SIDECAR_URL, "http://127.0.0.1:18789");
508        assert_eq!(SPAWN_TIMEOUT, Duration::from_secs(5));
509        assert_eq!(POLL_INTERVAL, Duration::from_millis(100));
510    }
511
512    #[tokio::test]
513    async fn test_send_success_via_mock() {
514        let mut server = mockito::Server::new_async().await;
515        let mock = server
516            .mock("POST", "/")
517            .with_status(200)
518            .with_header("content-type", "application/json")
519            .with_body(r#"{"status_code":200,"headers":{"x-test":"yes"},"body":"{\"data\":42}","error":""}"#)
520            .create_async()
521            .await;
522
523        let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
524        let req = Request::get("https://api.example.com/v1");
525        let resp = client.send(req).await.unwrap();
526
527        assert_eq!(resp.status_code, 200);
528        assert!(resp.is_success());
529        assert_eq!(resp.body, r#"{"data":42}"#);
530        assert_eq!(resp.headers.get("x-test").map(String::as_str), Some("yes"));
531        mock.assert_async().await;
532    }
533
534    #[tokio::test]
535    async fn test_send_with_stealth_off() {
536        let mut server = mockito::Server::new_async().await;
537        let mock = server
538            .mock("POST", "/")
539            .with_status(200)
540            .with_header("content-type", "application/json")
541            .with_body(r#"{"status_code":200,"headers":{},"body":"ok","error":""}"#)
542            .create_async()
543            .await;
544
545        let client = GholaHttpClient::with_base_url(false, 4096, &server.url()).unwrap();
546        let req = Request::post_json("https://api.example.com", r#"{"q":1}"#);
547        let resp = client.send(req).await.unwrap();
548
549        assert_eq!(resp.status_code, 200);
550        assert_eq!(resp.body, "ok");
551        mock.assert_async().await;
552    }
553
554    #[tokio::test]
555    async fn test_send_bridge_error_response() {
556        let mut server = mockito::Server::new_async().await;
557        let mock = server
558            .mock("POST", "/")
559            .with_status(200)
560            .with_header("content-type", "application/json")
561            .with_body(r#"{"status_code":0,"headers":{},"body":"","error":"upstream timeout"}"#)
562            .create_async()
563            .await;
564
565        let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
566        let req = Request::get("https://api.example.com");
567        let result = client.send(req).await;
568
569        assert!(result.is_err());
570        assert!(result.unwrap_err().to_string().contains("upstream timeout"));
571        mock.assert_async().await;
572    }
573
574    #[tokio::test]
575    async fn test_send_invalid_json_response() {
576        let mut server = mockito::Server::new_async().await;
577        let mock = server
578            .mock("POST", "/")
579            .with_status(200)
580            .with_body("not valid json at all")
581            .create_async()
582            .await;
583
584        let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
585        let req = Request::get("https://api.example.com");
586        let result = client.send(req).await;
587
588        assert!(result.is_err());
589        assert!(
590            result
591                .unwrap_err()
592                .to_string()
593                .contains("invalid sidecar response")
594        );
595        mock.assert_async().await;
596    }
597
598    #[tokio::test]
599    async fn test_send_non_success_bridge_status() {
600        let mut server = mockito::Server::new_async().await;
601        let mock = server
602            .mock("POST", "/")
603            .with_status(200)
604            .with_header("content-type", "application/json")
605            .with_body(r#"{"status_code":429,"headers":{},"body":"rate limited","error":""}"#)
606            .create_async()
607            .await;
608
609        let client = GholaHttpClient::with_base_url(false, 4096, &server.url()).unwrap();
610        let req = Request::get("https://api.example.com");
611        let resp = client.send(req).await.unwrap();
612
613        assert_eq!(resp.status_code, 429);
614        assert!(!resp.is_success());
615        assert_eq!(resp.body, "rate limited");
616        mock.assert_async().await;
617    }
618
619    #[tokio::test]
620    async fn test_send_with_custom_headers() {
621        let mut server = mockito::Server::new_async().await;
622        let mock = server
623            .mock("POST", "/")
624            .with_status(200)
625            .with_header("content-type", "application/json")
626            .with_body(r#"{"status_code":200,"headers":{},"body":"{}","error":""}"#)
627            .create_async()
628            .await;
629
630        let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
631        let req = Request::get("https://api.example.com")
632            .with_header("Authorization", "Bearer token")
633            .with_header("X-Chain", "ethereum");
634        let resp = client.send(req).await.unwrap();
635
636        assert!(resp.is_success());
637        mock.assert_async().await;
638    }
639
640    #[tokio::test]
641    async fn test_send_connection_refused() {
642        let client = GholaHttpClient::with_base_url(true, 4096, "http://127.0.0.1:1").unwrap();
643        let req = Request::get("https://api.example.com");
644        let result = client.send(req).await;
645
646        assert!(result.is_err());
647        assert!(
648            result
649                .unwrap_err()
650                .to_string()
651                .contains("failed to reach ghola sidecar")
652        );
653    }
654
655    #[tokio::test]
656    async fn test_send_includes_buffer_size() {
657        let mut server = mockito::Server::new_async().await;
658        let mock = server
659            .mock("POST", "/")
660            .match_body(mockito::Matcher::PartialJsonString(
661                r#"{"buffer_size":8192}"#.to_string(),
662            ))
663            .with_status(200)
664            .with_header("content-type", "application/json")
665            .with_body(r#"{"status_code":200,"headers":{},"body":"ok","error":""}"#)
666            .create_async()
667            .await;
668
669        let client = GholaHttpClient::with_base_url(true, 8192, &server.url()).unwrap();
670        let req = Request::get("https://api.example.com");
671        let resp = client.send(req).await.unwrap();
672
673        assert_eq!(resp.status_code, 200);
674        mock.assert_async().await;
675    }
676
677    #[test]
678    fn test_custom_buffer_size_stored() {
679        let client = GholaHttpClient::new(true, 16384).unwrap();
680        assert_eq!(client.buffer_size, 16384);
681    }
682
683    #[test]
684    fn test_default_buffer_size() {
685        let client = GholaHttpClient::new(false, 4096).unwrap();
686        assert_eq!(client.buffer_size, 4096);
687    }
688}