Skip to main content

rama/cli/service/
echo.rs

1//! Echo '[`Service`] that echos the [`http`] [`Request`] and [`tls`] client config.
2//!
3//! [`Service`]: crate::Service
4//! [`http`]: crate::http
5//! [`Request`]: crate::http::Request
6//! [`tls`]: crate::tls
7
8use crate::{
9    Layer, Service,
10    cli::ForwardKind,
11    combinators::{Either, Either3},
12    error::{BoxError, BoxErrorExt, ErrorContext},
13    extensions::ExtensionsRef,
14    http::{
15        BodyLimitLayer, Request, Response, Version,
16        body::util::BodyExt,
17        convert::curl,
18        core::h2::frame::EarlyFrameCapture,
19        fingerprint::{AkamaiH2, Ja4H},
20        header::USER_AGENT,
21        headers::exotic::XClacksOverhead,
22        layer::set_header::SetResponseHeaderLayer,
23        layer::{required_header::AddRequiredResponseHeadersLayer, trace::TraceLayer},
24        proto::h2::PseudoHeaderOrder,
25        server::HttpServer,
26        service::web::{extract::Json, response::IntoResponse},
27        ws::handshake::{
28            matcher::WebSocketMatcher,
29            server::{WebSocketAcceptor, WebSocketEchoService},
30        },
31    },
32    layer::limit::policy::UnlimitedPolicy,
33    layer::{
34        ConsumeErrLayer, LimitLayer, TimeoutLayer,
35        limit::policy::{ConcurrentPolicy, RateLimitReached, RatePolicy},
36    },
37    net::address::ip::geo::IpGeoDb,
38    net::forwarded::Forwarded,
39    net::stream::SocketInfo,
40    net::stream::layer::{ThrottleLayer, ThrottleMode},
41    net::{AuthorityInputExt, Protocol, ProtocolInputExt},
42    proxy::haproxy::server::HaProxyLayer,
43    rt::Executor,
44    tcp::TcpStream,
45    telemetry::tracing,
46    ua::{UserAgent, layer::classifier::UserAgentClassifierLayer, profile::UserAgentDatabase},
47    utils::{octets::mib, rate::Rate},
48};
49
50use rama_core::error::ErrorExt as _;
51use rama_http::layer::upgrade::UpgradeLayer;
52use serde::Serialize;
53use serde_json::json;
54use std::{convert::Infallible, sync::Arc, time::Duration};
55
56core::cfg_select! {
57    feature = "boring" => {
58        use crate::tls::boring::server::TlsAcceptorLayer;
59    }
60    feature = "rustls" => {
61        use crate::tls::rustls::server::TlsAcceptorLayer;
62    }
63    _ => {}
64}
65
66#[cfg(any(feature = "rustls", feature = "boring"))]
67use crate::{
68    tls::fingerprint::{Ja3, Ja4, PeetPrint},
69    tls::{
70        SecureTransport,
71        client::ClientHelloExtension,
72        client::{ECHClientHello, NegotiatedTlsParameters},
73        server::TlsServerConfig,
74    },
75};
76
77#[derive(Debug, Clone)]
78/// Builder that can be used to run your own echo [`Service`],
79/// echo'ing back information about that request and its underlying transport / presentation layers.
80pub struct EchoServiceBuilder<H> {
81    concurrent_limit: usize,
82    rate_limit: Option<Rate>,
83    throttle: Option<Rate>,
84    body_limit: usize,
85    timeout: Duration,
86    forward: Option<ForwardKind>,
87
88    #[cfg(any(feature = "rustls", feature = "boring"))]
89    tls_server_config: Option<TlsServerConfig>,
90
91    http_version: Option<Version>,
92
93    ws_support: bool,
94
95    http_service_builder: H,
96
97    uadb: Option<std::sync::Arc<UserAgentDatabase>>,
98
99    geo_db: Option<std::sync::Arc<IpGeoDb>>,
100}
101
102impl Default for EchoServiceBuilder<()> {
103    fn default() -> Self {
104        Self {
105            concurrent_limit: 0,
106            rate_limit: None,
107            throttle: None,
108            body_limit: mib(1),
109            timeout: Duration::ZERO,
110            forward: None,
111
112            #[cfg(any(feature = "rustls", feature = "boring"))]
113            tls_server_config: None,
114
115            http_version: None,
116
117            ws_support: false,
118
119            http_service_builder: (),
120
121            uadb: None,
122
123            geo_db: None,
124        }
125    }
126}
127
128impl EchoServiceBuilder<()> {
129    /// Create a new [`EchoServiceBuilder`].
130    #[must_use]
131    pub fn new() -> Self {
132        Self::default()
133    }
134}
135
136impl<H> EchoServiceBuilder<H> {
137    crate::utils::macros::generate_set_and_with! {
138        /// set the number of concurrent connections to allow
139        ///
140        /// (0 = no limit)
141        pub fn concurrent(mut self, limit: usize) -> Self {
142            self.concurrent_limit = limit;
143            self
144        }
145    }
146
147    crate::utils::macros::generate_set_and_with! {
148        /// rate limit the service in requests per second
149        /// (rejected with a 429 + Retry-After response)
150        pub fn rate_limit(mut self, rate: Option<Rate>) -> Self {
151            self.rate_limit = rate;
152            self
153        }
154    }
155
156    crate::utils::macros::generate_set_and_with! {
157        /// throttle each connection at the given byte rate
158        /// (both directions, each with its own budget)
159        pub fn throttle(mut self, rate: Option<Rate>) -> Self {
160            self.throttle = rate;
161            self
162        }
163    }
164
165    crate::utils::macros::generate_set_and_with! {
166        /// set the body limit in bytes for each request
167        pub fn body_limit(mut self, limit: usize) -> Self {
168            self.body_limit = limit;
169            self
170        }
171    }
172
173    crate::utils::macros::generate_set_and_with! {
174        /// set the timeout in seconds for each connection
175        ///
176        /// (0 = no timeout)
177        pub fn timeout(mut self, timeout: Duration) -> Self {
178            self.timeout = timeout;
179            self
180        }
181    }
182
183    crate::utils::macros::generate_set_and_with! {
184        /// enable support for one of the following "forward" headers or protocols
185        ///
186        /// Supported headers:
187        ///
188        /// Forwarded ("for="), X-Forwarded-For
189        ///
190        /// X-Client-IP Client-IP, X-Real-IP
191        ///
192        /// CF-Connecting-IP, True-Client-IP
193        ///
194        /// Or using HaProxy protocol.
195        pub fn forward(mut self, kind: Option<ForwardKind>) -> Self {
196            self.forward = kind;
197            self
198        }
199    }
200
201    crate::utils::macros::generate_set_and_with! {
202        #[cfg(any(feature = "rustls", feature = "boring"))]
203        /// define a tls server cert config to be used for tls terminaton
204        /// by the echo service.
205        pub fn tls_server_config(mut self, cfg: Option<TlsServerConfig>) -> Self {
206            self.tls_server_config = cfg;
207            self
208        }
209    }
210
211    crate::utils::macros::generate_set_and_with! {
212        /// set the http version to use for the http server (auto by default)
213        pub fn http_version(mut self, version: Option<Version>) -> Self {
214            self.http_version = version;
215            self
216        }
217    }
218
219    /// add a custom http layer which will be applied to the existing http layers
220    pub fn with_http_layer<H2>(self, layer: H2) -> EchoServiceBuilder<(H, H2)> {
221        EchoServiceBuilder {
222            concurrent_limit: self.concurrent_limit,
223            rate_limit: self.rate_limit,
224            throttle: self.throttle,
225            body_limit: self.body_limit,
226            timeout: self.timeout,
227            forward: self.forward,
228
229            #[cfg(any(feature = "rustls", feature = "boring"))]
230            tls_server_config: self.tls_server_config,
231
232            http_version: self.http_version,
233
234            ws_support: self.ws_support,
235
236            http_service_builder: (self.http_service_builder, layer),
237
238            uadb: self.uadb,
239
240            geo_db: self.geo_db,
241        }
242    }
243
244    crate::utils::macros::generate_set_and_with! {
245        /// maybe set the user agent datasbase that if set would be used to look up
246        /// a user agent (by ua header string) to see if we have a ja3/ja4 hash.
247        pub fn user_agent_database(
248            mut self,
249            db: Option<std::sync::Arc<UserAgentDatabase>>,
250        ) -> Self {
251            self.uadb = db;
252            self
253        }
254    }
255
256    crate::utils::macros::generate_set_and_with! {
257        /// attach an IP geolocation database, enabling geo enrichment of the
258        /// echoed JSON. Typically built from `RAMA_IP_GEO_DB`.
259        pub fn geo_db(mut self, db: Option<std::sync::Arc<IpGeoDb>>) -> Self {
260            self.geo_db = db;
261            self
262        }
263    }
264
265    crate::utils::macros::generate_set_and_with! {
266        /// define whether or not WS support is enabled
267        pub fn ws_support(
268            mut self,
269            support: bool,
270        ) -> Self {
271            self.ws_support = support;
272            self
273        }
274    }
275}
276
277impl<H> EchoServiceBuilder<H>
278where
279    H: Layer<EchoService, Service: Service<Request, Output = Response, Error = BoxError>>,
280{
281    #[expect(unused_mut)]
282    /// build a tcp service ready to echo http traffic back
283    pub fn build(
284        mut self,
285        exec: Executor,
286    ) -> Result<impl Service<TcpStream, Output = (), Error = Infallible>, BoxError> {
287        let tcp_forwarded_layer = match &self.forward {
288            Some(ForwardKind::HaProxy) => Some(HaProxyLayer::default()),
289            _ => None,
290        };
291
292        let http_service = Arc::new(self.build_http(exec.clone()));
293
294        let tcp_service_builder = (
295            ConsumeErrLayer::trace_as(tracing::Level::DEBUG),
296            LimitLayer::new(if self.concurrent_limit > 0 {
297                Either::A(ConcurrentPolicy::max(self.concurrent_limit))
298            } else {
299                Either::B(UnlimitedPolicy::new())
300            }),
301            if !self.timeout.is_zero() {
302                TimeoutLayer::new(self.timeout)
303            } else {
304                TimeoutLayer::never()
305            },
306            self.throttle
307                .map(|rate| ThrottleLayer::symmetric(ThrottleMode::per_conn(rate))),
308            tcp_forwarded_layer,
309            BodyLimitLayer::request_only(self.body_limit),
310            #[cfg(any(feature = "rustls", feature = "boring"))]
311            self.tls_server_config
312                .map(|cfg| TlsAcceptorLayer::new(cfg).with_store_client_hello(true)),
313        );
314
315        let http_transport_service = match self.http_version {
316            Some(Version::HTTP_2) => Either3::A({
317                let mut http = HttpServer::new_h2(exec);
318                if self.ws_support {
319                    http.h2_mut().set_enable_connect_protocol();
320                }
321                http.service(http_service)
322            }),
323            Some(Version::HTTP_11 | Version::HTTP_10 | Version::HTTP_09) => {
324                Either3::B(HttpServer::new_http1(exec).service(http_service))
325            }
326            Some(version) => {
327                return Err(BoxError::from_static_str("unsupported http version")
328                    .context_debug_field("version", version));
329            }
330            None => Either3::C({
331                let mut http = HttpServer::auto(exec);
332                if self.ws_support {
333                    http.h2_mut().set_enable_connect_protocol();
334                }
335                http.service(http_service)
336            }),
337        };
338
339        Ok(tcp_service_builder.into_layer(http_transport_service))
340    }
341
342    /// build an http service ready to echo http traffic back
343    pub fn build_http(
344        &self,
345        exec: Executor,
346    ) -> impl Service<Request, Output: IntoResponse, Error = Infallible> + use<H> {
347        let http_forwarded_layer = super::http_forwarded_layer(self.forward.as_ref());
348
349        // Attribution header, derived from the loaded databases' notices.
350        let geo_attribution = self.geo_db.as_ref().and_then(|db| {
351            let notices: Vec<_> = db.attributions().collect();
352            (!notices.is_empty()).then(|| crate::cli::service::geo::geo_attribution_layer(notices))
353        });
354
355        (
356            TraceLayer::new_for_http(),
357            SetResponseHeaderLayer::<XClacksOverhead>::if_not_present_default_typed(),
358            AddRequiredResponseHeadersLayer::default(),
359            self.rate_limit.map(|rate| {
360                LimitLayer::new(RatePolicy::abort(rate)).with_error_into_response_fn(
361                    |err: RateLimitReached| Ok::<_, Infallible>(err.into_response()),
362                )
363            }),
364            geo_attribution,
365            UserAgentClassifierLayer::new(),
366            ConsumeErrLayer::default(),
367            http_forwarded_layer,
368            self.ws_support.then(|| {
369                UpgradeLayer::new_with_services(
370                    exec,
371                    WebSocketMatcher::default(),
372                    {
373                        let acceptor = WebSocketAcceptor::default()
374                            .with_protocols_flex(true)
375                            .with_echo_protocols();
376
377                        #[cfg(feature = "compression")]
378                        {
379                            acceptor.with_per_message_deflate_overwrite_extensions()
380                        }
381                        #[cfg(not(feature = "compression"))]
382                        {
383                            acceptor
384                        }
385                    },
386                    ConsumeErrLayer::trace_as(tracing::Level::DEBUG)
387                        .into_layer(WebSocketEchoService::default()),
388                )
389            }),
390        )
391            .into_layer(self.http_service_builder.layer(EchoService {
392                uadb: self.uadb.clone(),
393                geo_db: self.geo_db.clone(),
394            }))
395    }
396}
397
398#[derive(Debug, Clone)]
399#[non_exhaustive]
400/// The inner echo-service used by the [`EchoServiceBuilder`].
401pub struct EchoService {
402    uadb: Option<std::sync::Arc<UserAgentDatabase>>,
403    geo_db: Option<std::sync::Arc<IpGeoDb>>,
404}
405
406#[derive(Debug, Serialize)]
407struct CurlScripts {
408    unix: String,
409    powershell: String,
410}
411
412fn curl_scripts(
413    parts: &crate::http::request::Parts,
414    payload: &rama_core::bytes::Bytes,
415) -> Result<CurlScripts, curl::CurlPayloadRequiresStdin> {
416    let render = |compatibility| {
417        curl::try_cmd_string_for_request_parts_and_payload_with_options(
418            parts,
419            payload,
420            curl::CurlExportOptions::default().with_script_compatibility(compatibility),
421            &curl::CurlScriptPayloadMode::Inline,
422        )
423    };
424
425    Ok(CurlScripts {
426        unix: render(curl::CurlScriptCompatibility::Unix)?,
427        powershell: render(curl::CurlScriptCompatibility::PowerShell)?,
428    })
429}
430
431impl Service<Request> for EchoService {
432    type Output = Response;
433    type Error = BoxError;
434
435    async fn serve(&self, req: Request) -> Result<Self::Output, Self::Error> {
436        let user_agent_info = req
437            .extensions()
438            .get_ref()
439            .map(|ua: &UserAgent| {
440                json!({
441                    "user_agent": ua.header_str().to_owned(),
442                    "kind": ua.info().map(|info| info.kind.to_string()),
443                    "version": ua.info().and_then(|info| info.version),
444                    "platform": ua.platform().map(|v| v.to_string()),
445                })
446            })
447            .unwrap_or_default();
448
449        let authority = req
450            .authority()
451            .context("echo: resolve request authority")?
452            .to_string();
453        let scheme = req.protocol().unwrap_or(&Protocol::HTTP).to_string();
454
455        let ua_str = req
456            .headers()
457            .get(USER_AGENT)
458            .and_then(|h| h.to_str().ok())
459            .map(ToOwned::to_owned);
460        tracing::debug!(
461            user_agent.original = ua_str,
462            "echo request received from ua with ua header",
463        );
464
465        #[derive(Debug, Serialize)]
466        struct FingerprintProfileData {
467            hash: String,
468            verbose: String,
469            matched: bool,
470        }
471
472        let ja4h = Ja4H::compute(&req)
473            .inspect_err(|err| tracing::error!("ja4h compute failure: {err:?}"))
474            .ok()
475            .map(|ja4h| {
476                let mut profile_ja4h: Option<FingerprintProfileData> = None;
477
478                if let Some(uadb) = self.uadb.as_deref()
479                    && let Some(profile) =
480                        ua_str.as_deref().and_then(|s| uadb.get_exact_header_str(s))
481                    {
482                        let matched_ja4h = match req.version() {
483                            Version::HTTP_10 | Version::HTTP_11 => profile
484                                .http
485                                .ja4h_h1_navigate(Some(req.method().clone()))
486                                .inspect_err(|err| {
487                                    tracing::trace!(
488                                        "ja4h computation of matched profile for incoming h1 req: {err:?}"
489                                    )
490                                })
491                                .ok(),
492                            Version::HTTP_2 => profile
493                                .http
494                                .ja4h_h2_navigate(Some(req.method().clone()))
495                                .inspect_err(|err| {
496                                    tracing::trace!(
497                                        "ja4h computation of matched profile for incoming h2 req: {err:?}"
498                                    )
499                                })
500                                .ok(),
501                            _ => None,
502                        };
503                        if let Some(tgt) = matched_ja4h {
504                            let hash = format!("{tgt}");
505                            let matched = format!("{ja4h}") == hash;
506                            profile_ja4h = Some(FingerprintProfileData {
507                                hash,
508                                verbose: format!("{tgt:?}"),
509                                matched,
510                            });
511                        }
512                    }
513
514                json!({
515                    "hash": format!("{ja4h}"),
516                    "verbose": format!("{ja4h:?}"),
517                    "profile": profile_ja4h,
518                })
519            });
520
521        let (parts, body) = req.into_parts();
522
523        let body = body
524            .collect()
525            .await
526            .context("collect request body for echo purposes")?
527            .to_bytes();
528
529        let curl_request =
530            curl_scripts(&parts, &body).context("create curl command for echo response")?;
531
532        let headers: Vec<_> = parts
533            .headers
534            .into_ordered_iter()
535            .map(|(name, value)| {
536                (
537                    name,
538                    std::str::from_utf8(value.as_bytes())
539                        .map(|s| s.to_owned())
540                        .unwrap_or_else(|_| format!("0x{:x?}", value.as_bytes())),
541                )
542            })
543            .collect();
544
545        let body = hex::encode(body.as_ref());
546
547        #[cfg(any(feature = "rustls", feature = "boring"))]
548        let tls_info = parts
549            .extensions
550            .get_ref::<SecureTransport>()
551            .and_then(|st| st.client_hello())
552            .map(|hello| {
553                let ja4 = Ja4::compute(parts.extensions.extensions())
554                    .inspect_err(|err| tracing::trace!("ja4 computation: {err:?}"))
555                    .ok();
556
557                let mut profile_ja4: Option<FingerprintProfileData> = None;
558
559                if let Some(uadb) = self.uadb.as_deref()
560                    && let Some(profile) =
561                        ua_str.as_deref().and_then(|s| uadb.get_exact_header_str(s))
562                {
563                    let matched_ja4 = profile
564                        .tls
565                        .compute_ja4(
566                            parts
567                                .extensions
568                                .get_ref::<NegotiatedTlsParameters>()
569                                .map(|param| param.protocol_version),
570                        )
571                        .inspect_err(|err| {
572                            tracing::trace!("ja4 computation of matched profile: {err:?}")
573                        })
574                        .ok();
575                    if let (Some(src), Some(tgt)) = (ja4.as_ref(), matched_ja4) {
576                        let hash = format!("{tgt}");
577                        let matched = format!("{src}") == hash;
578                        profile_ja4 = Some(FingerprintProfileData {
579                            hash,
580                            verbose: format!("{tgt:?}"),
581                            matched,
582                        });
583                    }
584                }
585
586                let ja4 = ja4.map(|ja4| {
587                    json!({
588                        "hash": format!("{ja4}"),
589                        "verbose": format!("{ja4:?}"),
590                        "profile": profile_ja4,
591                    })
592                });
593
594                let ja3 = Ja3::compute(parts.extensions.extensions())
595                    .inspect_err(|err| tracing::trace!("ja3 computation: {err:?}"))
596                    .ok();
597
598                let mut profile_ja3: Option<FingerprintProfileData> = None;
599
600                if let Some(uadb) = self.uadb.as_deref()
601                    && let Some(profile) =
602                        ua_str.as_deref().and_then(|s| uadb.get_exact_header_str(s))
603                {
604                    let matched_ja3 = profile
605                        .tls
606                        .compute_ja3(
607                            parts
608                                .extensions
609                                .get_ref::<NegotiatedTlsParameters>()
610                                .map(|param| param.protocol_version),
611                        )
612                        .inspect_err(|err| {
613                            tracing::trace!("ja3 computation of matched profile: {err:?}")
614                        })
615                        .ok();
616                    if let (Some(src), Some(tgt)) = (ja3.as_ref(), matched_ja3) {
617                        let hash = format!("{tgt:x}");
618                        let matched = format!("{src:x}") == hash;
619                        profile_ja3 = Some(FingerprintProfileData {
620                            hash,
621                            verbose: format!("{tgt}"),
622                            matched,
623                        });
624                    }
625                }
626
627                let ja3 = ja3.map(|ja3| {
628                    json!({
629                        "hash": format!("{ja3:x}"),
630                        "verbose": format!("{ja3}"),
631                        "profile": profile_ja3,
632                    })
633                });
634
635                let peet = PeetPrint::compute(parts.extensions.extensions())
636                    .inspect_err(|err| tracing::trace!("peet computation: {err:?}"))
637                    .ok();
638
639                let mut profile_peet: Option<FingerprintProfileData> = None;
640
641                if let Some(uadb) = self.uadb.as_deref()
642                    && let Some(profile) =
643                        ua_str.as_deref().and_then(|s| uadb.get_exact_header_str(s))
644                {
645                    let matched_peet = profile
646                        .tls
647                        .compute_peet()
648                        .inspect_err(|err| {
649                            tracing::trace!("peetprint computation of matched profile: {err:?}")
650                        })
651                        .ok();
652                    if let (Some(src), Some(tgt)) = (peet.as_ref(), matched_peet) {
653                        let hash = format!("{tgt}");
654                        let matched = format!("{src}") == hash;
655                        profile_peet = Some(FingerprintProfileData {
656                            hash,
657                            verbose: format!("{tgt:?}"),
658                            matched,
659                        });
660                    }
661                }
662
663                let peet = peet.map(|peet| {
664                    json!({
665                        "hash": format!("{peet}"),
666                        "verbose": format!("{peet:?}"),
667                        "profile": profile_peet,
668                    })
669                });
670
671                json!({
672                    "header": {
673                        "version": hello.protocol_version().to_string(),
674                        "cipher_suites": hello
675                        .cipher_suites().iter().map(|s| s.to_string()).collect::<Vec<_>>(),
676                        "compression_algorithms": hello
677                        .compression_algorithms().iter().map(|s| s.to_string()).collect::<Vec<_>>(),
678                    },
679                    "extensions": hello.extensions().iter().map(|extension| match extension {
680                        ClientHelloExtension::ServerName(domain) => json!({
681                            "id": extension.id().to_string(),
682                            "data": domain,
683                        }),
684                        ClientHelloExtension::SignatureAlgorithms(v) => json!({
685                            "id": extension.id().to_string(),
686                            "data": v.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
687                        }),
688                        ClientHelloExtension::SupportedVersions(v) => json!({
689                            "id": extension.id().to_string(),
690                            "data": v.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
691                        }),
692                        ClientHelloExtension::ApplicationLayerProtocolNegotiation(v) => json!({
693                            "id": extension.id().to_string(),
694                            "data": v.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
695                        }),
696                        ClientHelloExtension::ApplicationSettings{ protocols, .. } => json!({
697                            "id": extension.id().to_string(),
698                            "data": protocols.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
699                        }),
700                        ClientHelloExtension::SupportedGroups(v) => json!({
701                            "id": extension.id().to_string(),
702                            "data": v.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
703                        }),
704                        ClientHelloExtension::ECPointFormats(v) => json!({
705                            "id": extension.id().to_string(),
706                            "data": v.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
707                        }),
708                        ClientHelloExtension::CertificateCompression(v) => json!({
709                            "id": extension.id().to_string(),
710                            "data": v.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
711                        }),
712                        ClientHelloExtension::DelegatedCredentials(v) => json!({
713                            "id": extension.id().to_string(),
714                            "data": v.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
715                        }),
716                        ClientHelloExtension::RecordSizeLimit(v) => json!({
717                            "id": extension.id().to_string(),
718                            "data": v.to_string(),
719                        }),
720                        ClientHelloExtension::EncryptedClientHello(ech) => match ech {
721                            ECHClientHello::Outer(ech) => json!({
722                                "id": extension.id().to_string(),
723                                "data": {
724                                    "type": "outer",
725                                    "cipher_suite": {
726                                        "aead_id": ech.cipher_suite.aead_id.to_string(),
727                                        "kdf_id": ech.cipher_suite.kdf_id.to_string(),
728                                    },
729                                    "config_id": ech.config_id,
730                                    "enc":  format!("0x{}", hex::encode(&ech.enc)),
731                                    "payload": format!("0x{}", hex::encode(&ech.payload)),
732                                },
733                            }),
734                            ECHClientHello::Inner => json!({
735                                "id": extension.id().to_string(),
736                                "data": {
737                                    "type": "inner",
738                                },
739                            })
740
741                        }
742                        ClientHelloExtension::Opaque { id, data } => if data.is_empty() {
743                            json!({
744                                "id": id.to_string()
745                            })
746                        } else {
747                            json!({
748                                "id": id.to_string(),
749                                "data": format!("0x{}", hex::encode(data))
750                            })
751                        },
752                    }).collect::<Vec<_>>(),
753                    "ja3": ja3,
754                    "ja4": ja4,
755                    "peet": peet
756                })
757            });
758
759        #[cfg(not(any(feature = "rustls", feature = "boring")))]
760        let tls_info: Option<()> = None;
761
762        let mut h2 = None;
763        if parts.version == Version::HTTP_2 {
764            let early_frames = parts.extensions.get_ref::<EarlyFrameCapture>();
765            let pseudo_headers = parts.extensions.get_ref::<PseudoHeaderOrder>();
766            let akamai_h2 = AkamaiH2::compute(&parts.extensions)
767                .inspect_err(|err| tracing::trace!("akamai h2 compute failure: {err:?}"))
768                .ok()
769                .map(|akamai| {
770                    json!({
771                        "hash": format!("{akamai}"),
772                        "verbose": format!("{akamai:?}"),
773                    })
774                });
775
776            h2 = Some(json!({
777                "early_frames": early_frames,
778                "pseudo_headers": pseudo_headers,
779                "akamai_h2": akamai_h2,
780            }));
781        }
782
783        // attribution rides in the x-geo-attribution response header, not the body
784        let geo = self
785            .geo_db
786            .as_ref()
787            .and_then(|db| {
788                parts
789                    .extensions
790                    .get_ref::<Forwarded>()
791                    .and_then(|f| f.client_ip())
792                    .or_else(|| {
793                        parts
794                            .extensions
795                            .get_ref::<SocketInfo>()
796                            .map(|s| s.peer_addr().ip_addr)
797                    })
798                    .and_then(|ip| db.resolve(ip))
799            })
800            .map(|info| serde_json::to_value(&info).unwrap_or_default())
801            .unwrap_or(serde_json::Value::Null);
802
803        Ok(Json(json!({
804            "ua": user_agent_info,
805            "geo": geo,
806            "http": {
807                "version": format!("{:?}", parts.version),
808                "scheme": scheme,
809                "method": format!("{:?}", parts.method),
810                "authority": authority,
811                "path": parts.uri.path_or_root().into_owned(),
812                "query": parts.uri.query().map(|q| q.as_encoded_str().into_owned()),
813                "h2": h2,
814                "headers": headers,
815                "payload": body,
816                "ja4h": ja4h,
817                "curl": curl_request,
818            },
819            "tls": tls_info,
820            "socket_addr": parts.extensions.get_ref::<Forwarded>()
821                .and_then(|f|
822                        f.client_socket_addr().map(|addr| addr.to_string())
823                            .or_else(|| f.client_ip().map(|ip| ip.to_string()))
824                ).or_else(|| parts.extensions.get_ref::<SocketInfo>().map(|v| v.peer_addr().to_string())),
825        }))
826        .into_response())
827    }
828}
829
830#[cfg(test)]
831mod tests {
832    use super::*;
833    use crate::http::{Body, StatusCode};
834
835    #[tokio::test]
836    async fn binary_request_body_is_echoed_with_lossless_platform_curl_scripts() {
837        let payload = rama_core::bytes::Bytes::from_static(b"\0\xfftail\n");
838        let request = Request::builder()
839            .method("POST")
840            .uri("http://example.com/upload")
841            .body(Body::from(payload))
842            .unwrap();
843
844        let response = EchoService {
845            uadb: None,
846            geo_db: None,
847        }
848        .serve(request)
849        .await
850        .expect("echo binary request");
851        assert_eq!(response.status(), StatusCode::OK);
852
853        let body = response
854            .into_body()
855            .collect()
856            .await
857            .expect("collect echo response")
858            .to_bytes();
859        let json: serde_json::Value = serde_json::from_slice(&body).expect("parse echo response");
860
861        assert_eq!(json["http"]["payload"], "00ff7461696c0a");
862        let unix = json["http"]["curl"]["unix"]
863            .as_str()
864            .expect("Unix curl script");
865        assert!(unix.contains("base64 -d"));
866        assert!(unix.contains("--data-binary '@-'"));
867        let powershell = json["http"]["curl"]["powershell"]
868            .as_str()
869            .expect("PowerShell curl script");
870        assert!(powershell.contains("[Convert]::FromBase64String"));
871        assert!(powershell.contains("--data-binary ('@' + $__ramaCurlPayload)"));
872    }
873}