Skip to main content

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