Skip to main content

saluki_components/relays/apm/
mod.rs

1//! APM relay.
2//!
3//! Listens on TCP and/or a Unix domain socket where the trace-agent normally listens and forwards every request
4//! to the relocated trace-agent.
5
6use std::net::{IpAddr, SocketAddr};
7use std::sync::{Arc, LazyLock};
8
9use agent_data_plane_config::{ConfigValue, SalukiConfiguration};
10use async_trait::async_trait;
11use axum::extract::{ConnectInfo, Request as AxumRequest, State};
12use axum::response::IntoResponse;
13use axum::Router;
14use http::{HeaderName, HeaderValue};
15use metrics::Counter;
16use saluki_api::APIHandler;
17use saluki_core::accounting::{MemoryBounds, MemoryBoundsBuilder};
18use saluki_core::components::relays::{Relay, RelayBuilder, RelayContext};
19use saluki_core::components::BuildContext;
20use saluki_core::data_model::payload::PayloadType;
21use saluki_core::runtime;
22use saluki_core::topology::OutputDefinition;
23use saluki_env::workload::{CaptureEntityResolver, EntityId};
24use saluki_error::{generic_error, ErrorContext as _, GenericError};
25use saluki_io::net::dns::resolve_host;
26use saluki_io::net::server::http::HttpServer;
27use saluki_io::net::{ConnectionAddress, ListenAddress};
28use saluki_metrics::static_metrics;
29use tokio::{pin, select};
30use tracing::debug;
31
32mod proxy;
33use self::proxy::{Destination, TraceAgentProxy};
34
35/// Header a tracer sends its container ID in.
36const CONTAINER_ID_HEADER: HeaderName = HeaderName::from_static("datadog-container-id");
37
38/// Header a tracer sends its origin in (for example, `ci-<container ID>` or `in-<cgroup inode>`), which the trace-agent
39/// prefers over `Datadog-Container-ID`.
40const ENTITY_ID_HEADER: HeaderName = HeaderName::from_static("datadog-entity-id");
41
42/// Configuration for the APM relay.
43///
44/// Holds plain values, populated by the binary that assembles the topology, plus an optional resolver for the
45/// container of a tracer connected over the Unix domain socket.
46///
47/// `receiver_socket` is an empty `String` rather than an `Option<String>`, so that "disabled" has a single
48/// representation.
49#[derive(Clone)]
50pub struct ApmRelayConfiguration {
51    /// Unix domain socket path the relay listens on.
52    ///
53    /// Empty disables the Unix domain socket listener.
54    receiver_socket: String,
55
56    /// URL of the trace-agent that every request not handled by ADP is proxied to: `http://host:port` or
57    /// `unix:///path/to/socket`.
58    proxy_destination: String,
59
60    /// Resolver for mapping the process ID of a tracer connected over the Unix domain socket to its container.
61    ///
62    /// The trace-agent finds the container of a tracer that sends no container headers from the peer credentials of
63    /// its Unix domain socket connection, but behind the relay those credentials are ADP's. With a resolver, the relay
64    /// resolves the container from the tracer's credentials itself, and passes it on as `Datadog-Container-ID`.
65    capture_entity_resolver: Option<Arc<dyn CaptureEntityResolver + Send + Sync>>,
66
67    /// The host address to bind the APM relay TCP listeners to.
68    ///
69    /// Accepts either an IP literal (for example, `192.168.1.50`, `::1`) or a hostname that resolves via DNS (for example, `agent.internal`).
70    /// Ignored when `apm_non_local_traffic` is `true`, and binds to `127.0.0.1` when unset.
71    bind_host: Option<String>,
72
73    /// Whether the TCP listener accepts traffic from other hosts.
74    ///
75    /// When `true`, the listener binds every interface, overriding `bind_host`, as the trace-agent does.
76    non_local_traffic: bool,
77
78    /// TCP port the trace receiver listens on.
79    ///
80    /// `0` disables the TCP listener.
81    receiver_port: u16,
82}
83
84#[static_metrics(prefix = component, labels(component_id, component_type))]
85#[derive(Clone)]
86struct Metrics {
87    requests_relayed_total: Counter,
88    requests_failed_total: Counter,
89}
90
91impl ApmRelayConfiguration {
92    /// Creates a new `ApmRelayConfiguration` from the resolved configuration.
93    ///
94    /// `containerized` is whether ADP runs in the Datadog Agent's container image, where the TCP listener binds every
95    /// interface unless its host is configured, as the trace-agent's does.
96    pub fn from_configuration(config: &SalukiConfiguration, containerized: bool) -> Self {
97        let (bind_host, non_local_traffic) = resolve_listen_host(
98            &config.shared.bind_host,
99            config.domains.apm.non_local_traffic,
100            containerized,
101        );
102
103        // receiver_port `0` disables the TCP listener
104        Self {
105            receiver_socket: config.domains.apm.receiver_socket.clone().unwrap_or_default(),
106            proxy_destination: config.domains.apm.proxy_destination.to_owned(),
107            capture_entity_resolver: None,
108            bind_host,
109            non_local_traffic,
110            receiver_port: config.domains.apm.receiver_port,
111        }
112    }
113
114    /// Sets the resolver used to find the container of a tracer connected over the Unix domain socket.
115    pub fn with_capture_entity_resolver(mut self, resolver: Arc<dyn CaptureEntityResolver + Send + Sync>) -> Self {
116        self.capture_entity_resolver = Some(resolver);
117        self
118    }
119
120    async fn tcp_listen_address(&self) -> Result<Option<ListenAddress>, GenericError> {
121        if self.receiver_port == 0 {
122            return Ok(None);
123        };
124
125        let listen_address = if self.non_local_traffic {
126            [0, 0, 0, 0].into()
127        } else if let Some(bind_host) = &self.bind_host {
128            resolve_host(bind_host)
129                .await
130                .error_context("Could not resolve bind_host for the APM relay.")?
131        } else {
132            [127, 0, 0, 1].into()
133        };
134
135        Ok(Some(ListenAddress::Tcp(SocketAddr::new(
136            listen_address,
137            self.receiver_port,
138        ))))
139    }
140
141    fn uds_listen_address(&self) -> Result<Option<ListenAddress>, GenericError> {
142        if self.receiver_socket.is_empty() {
143            return Ok(None);
144        }
145
146        ListenAddress::try_from(format!("unix://{}", self.receiver_socket))
147            .map(Some)
148            .map_err(|e| {
149                generic_error!(
150                    "Invalid APM relay Unix domain socket path `{}`: {e}",
151                    self.receiver_socket
152                )
153            })
154    }
155}
156
157/// Resolves the host the TCP listener binds to, as the trace-agent resolves its receiver host.
158///
159/// Returns the host to bind, if any, and whether to bind every interface instead. When `bind_host` or
160/// `apm_non_local_traffic` was set explicitly, they apply as configured. When neither was, the listener binds every
161/// interface in a container, so that tracers in other containers can reach it, and the default host otherwise.
162fn resolve_listen_host(
163    bind_host: &ConfigValue<Option<String>>, non_local_traffic: ConfigValue<bool>, containerized: bool,
164) -> (Option<String>, bool) {
165    if bind_host.is_explicit() || non_local_traffic.is_explicit() {
166        let host = if bind_host.is_explicit() {
167            bind_host.value.clone()
168        } else {
169            None
170        };
171        (host, non_local_traffic.value)
172    } else {
173        (None, containerized)
174    }
175}
176
177/// Returns the relay listener that `destination` reaches, if any.
178///
179/// This catches the obvious misconfigurations, such as the trace-agent's default port used for both, not every route
180/// back to the relay: a listener on every interface is also reached through the host's other addresses, for example.
181async fn listener_reached_by<'a>(
182    destination: &Destination, tcp_endpoint: Option<&'a ListenAddress>, uds_endpoint: Option<&'a ListenAddress>,
183) -> Option<&'a ListenAddress> {
184    // Only Unix domain socket destinations can reach the Unix domain socket listener.
185    #[cfg(not(unix))]
186    let _ = uds_endpoint;
187
188    match destination {
189        Destination::Tcp { host, port } => {
190            let endpoint = tcp_endpoint?;
191            let ListenAddress::Tcp(listener) = endpoint else {
192                return None;
193            };
194            // A destination that doesn't resolve yet can't be told apart from the listener, so it is let through.
195            let mut destinations = tokio::net::lookup_host((host.as_str(), *port)).await.ok()?;
196            destinations
197                .any(|destination| tcp_reaches(destination, *listener))
198                .then_some(endpoint)
199        }
200        #[cfg(unix)]
201        Destination::Unix(path) => {
202            uds_endpoint.filter(|endpoint| matches!(endpoint, ListenAddress::Unix(listener) if listener == path))
203        }
204    }
205}
206
207/// Returns whether connecting to `destination` reaches a TCP listener bound to `listener`.
208///
209/// A listener on the unspecified address accepts connections to any local address, and connecting to the unspecified
210/// address connects to the local host, so either side being unspecified matches a loopback address of the same family.
211fn tcp_reaches(destination: SocketAddr, listener: SocketAddr) -> bool {
212    let is_local = |ip: IpAddr| ip.is_loopback() || ip.is_unspecified();
213    let (destination_ip, listener_ip) = (destination.ip(), listener.ip());
214
215    destination.port() == listener.port()
216        && (destination_ip == listener_ip
217            || ((destination_ip.is_unspecified() || listener_ip.is_unspecified())
218                && destination_ip.is_ipv4() == listener_ip.is_ipv4()
219                && is_local(destination_ip)
220                && is_local(listener_ip)))
221}
222
223impl MemoryBounds for ApmRelayConfiguration {
224    fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
225        builder
226            .minimum()
227            .with_single_value::<ApmRelay>("component struct")
228            .with_single_value::<RelayState>("component struct");
229    }
230}
231
232#[async_trait]
233impl RelayBuilder for ApmRelayConfiguration {
234    fn outputs(&self) -> &[OutputDefinition<PayloadType>] {
235        static OUTPUTS: LazyLock<Vec<OutputDefinition<PayloadType>>> =
236            LazyLock::new(|| vec![OutputDefinition::named_output("traces", PayloadType::Http)]);
237        &OUTPUTS
238    }
239
240    async fn build(&self, context: BuildContext) -> Result<Box<dyn Relay + Send>, GenericError> {
241        let tcp_endpoint = self.tcp_listen_address().await?;
242        let uds_endpoint = self.uds_listen_address()?;
243
244        if tcp_endpoint.is_none() && uds_endpoint.is_none() {
245            return Err(generic_error!(
246                "APM relay requires at least one of `apm_config.receiver_port` or `apm_config.receiver_socket` to be configured."
247            ));
248        }
249
250        let proxy = TraceAgentProxy::from_destination(&self.proxy_destination)?;
251        if let Some(listener) =
252            listener_reached_by(proxy.destination(), tcp_endpoint.as_ref(), uds_endpoint.as_ref()).await
253        {
254            return Err(generic_error!(
255                "APM proxy destination `{}` is the relay's own listener `{listener}`, so every request would be proxied back to the relay. Set `data_plane.experimental.apm.proxy_destination` to the trace-agent's address.",
256                self.proxy_destination
257            ));
258        }
259
260        Ok(Box::new(ApmRelay {
261            tcp_endpoint,
262            uds_endpoint,
263            proxy,
264            capture_entity_resolver: self.capture_entity_resolver.clone(),
265            metrics: Metrics::new(context.component_id(), context.component_type().as_category_str()),
266        }))
267    }
268}
269
270/// APM relay.
271///
272/// Receives requests via HTTP, on TCP and/or a Unix domain socket, and proxies them to the trace-agent.
273pub struct ApmRelay {
274    tcp_endpoint: Option<ListenAddress>,
275    uds_endpoint: Option<ListenAddress>,
276    proxy: TraceAgentProxy,
277    capture_entity_resolver: Option<Arc<dyn CaptureEntityResolver + Send + Sync>>,
278    metrics: Metrics,
279}
280
281#[async_trait]
282impl Relay for ApmRelay {
283    async fn run(self: Box<Self>, mut context: RelayContext) -> Result<(), GenericError> {
284        let Self {
285            tcp_endpoint,
286            uds_endpoint,
287            proxy,
288            capture_entity_resolver,
289            metrics,
290        } = *self;
291
292        let global_shutdown = context.take_shutdown_handle();
293        pin!(global_shutdown);
294
295        let mut health = context.take_health_handle();
296        let worker_pool = context.topology_context().global_thread_pool().clone();
297        let api_handler = ApmRelayAPIHandler::new(RelayState {
298            metrics,
299            proxy,
300            capture_entity_resolver,
301        });
302        let router = api_handler
303            .generate_routes()
304            .with_state(api_handler.generate_initial_state());
305
306        // The listeners are needed: one that fails to bind, for example because the trace-agent still owns its address,
307        // fails the relay rather than leaving it ready without serving that endpoint.
308        if let Some(endpoint) = tcp_endpoint {
309            debug!(%endpoint, "Binding APM relay TCP listener.");
310            let server = HttpServer::from_listen_address(endpoint)
311                .with_routes(router.clone())
312                .with_worker_pool(worker_pool.clone());
313            runtime::nested_supervisor(server.into_supervisor())
314                .needed()
315                .stops_with_owner()
316                .spawn();
317        }
318        if let Some(endpoint) = uds_endpoint {
319            debug!(%endpoint, "Binding APM relay Unix domain socket listener.");
320            let server = HttpServer::from_listen_address(endpoint)
321                .with_routes(router.clone())
322                .with_worker_pool(worker_pool);
323            runtime::nested_supervisor(server.into_supervisor())
324                .needed()
325                .stops_with_owner()
326                .spawn();
327        }
328
329        health.mark_ready();
330        debug!("APM relay started.");
331
332        loop {
333            select! {
334                _ = &mut global_shutdown => {
335                    debug!("Received shutdown signal.");
336                    break
337                },
338                _ = health.live() => continue,
339            }
340        }
341
342        debug!("APM relay stopped.");
343
344        Ok(())
345    }
346}
347
348/// Shared HTTP handler state.
349#[derive(Clone)]
350struct RelayState {
351    proxy: TraceAgentProxy,
352    capture_entity_resolver: Option<Arc<dyn CaptureEntityResolver + Send + Sync>>,
353    metrics: Metrics,
354}
355
356struct ApmRelayAPIHandler {
357    state: RelayState,
358}
359
360impl ApmRelayAPIHandler {
361    fn new(state: RelayState) -> Self {
362        Self { state }
363    }
364
365    async fn proxy_request(
366        State(RelayState {
367            proxy,
368            capture_entity_resolver,
369            metrics,
370        }): State<RelayState>,
371        mut request: AxumRequest,
372    ) -> impl IntoResponse {
373        if let Some(resolver) = capture_entity_resolver.as_deref() {
374            add_peer_container_id(&mut request, resolver);
375        }
376        let result = proxy.forward(request).await;
377        match &result {
378            Ok(_) => metrics.requests_relayed_total().increment(1),
379            Err(_) => metrics.requests_failed_total().increment(1),
380        }
381        result
382    }
383}
384
385/// Sets `Datadog-Container-ID` to the container of the tracer that sent `request`, when the tracer is connected over the
386/// Unix domain socket and sent no container headers of its own.
387///
388/// This stands in for the trace-agent's own lookup from the connection's peer credentials, which behind the relay
389/// would find ADP rather than the tracer.
390fn add_peer_container_id(request: &mut AxumRequest, resolver: &(dyn CaptureEntityResolver + Send + Sync)) {
391    let headers = request.headers();
392    if headers.contains_key(CONTAINER_ID_HEADER) || headers.contains_key(ENTITY_ID_HEADER) {
393        return;
394    }
395
396    let Some(process_id) = request
397        .extensions()
398        .get::<ConnectInfo<ConnectionAddress>>()
399        .and_then(|ConnectInfo(addr)| addr.process_credentials())
400        .and_then(|creds| u32::try_from(creds.pid).ok())
401    else {
402        return;
403    };
404
405    match resolver.resolve_container_entity_for_live_pid(process_id) {
406        Some(EntityId::Container(container_id)) => match HeaderValue::from_str(&container_id) {
407            Ok(value) => {
408                request.headers_mut().insert(CONTAINER_ID_HEADER, value);
409            }
410            Err(_) => debug!(process_id, %container_id, "Resolved container ID is not a valid header value."),
411        },
412        Some(entity_id) => debug!(process_id, %entity_id, "Tracer process resolved to a non-container entity."),
413        None => debug!(process_id, "Could not resolve the container of the tracer process."),
414    }
415}
416
417impl APIHandler for ApmRelayAPIHandler {
418    type State = RelayState;
419
420    fn generate_initial_state(&self) -> Self::State {
421        self.state.clone()
422    }
423
424    /// Forwards every request to the trace-agent.
425    fn generate_routes(&self) -> Router<Self::State> {
426        Router::new().fallback(Self::proxy_request)
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use std::net::SocketAddr;
433    use std::time::Duration;
434
435    use http::{Request, StatusCode};
436    use saluki_core::components::test_util::TestComponentDriver;
437    use saluki_core::runtime::state::{DataspaceUpdate, IdentifierFilter};
438    use saluki_core::runtime::test_util::TestSupervisor;
439    use saluki_io::net::{BoundListenAddress, ProcessCredentials};
440    #[cfg(unix)]
441    use tempfile::tempdir;
442    use tokio::io::{AsyncReadExt, AsyncWriteExt};
443    use tokio::net::TcpStream;
444    #[cfg(unix)]
445    use tokio::net::UnixStream;
446    use tower::ServiceExt;
447
448    use super::*;
449
450    /// A proxy whose trace-agent is not listening, so a proxied request is answered `502 Bad Gateway`. That makes
451    /// "was this request proxied?" observable without running a trace-agent.
452    fn unreachable_proxy() -> TraceAgentProxy {
453        TraceAgentProxy::from_destination("http://127.0.0.1:1").expect("destination should parse")
454    }
455
456    fn build_router(state: RelayState) -> Router {
457        let api_handler = ApmRelayAPIHandler::new(state);
458        api_handler
459            .generate_routes()
460            .with_state(api_handler.generate_initial_state())
461    }
462
463    fn test_router() -> Router {
464        build_router(RelayState {
465            proxy: unreachable_proxy(),
466            capture_entity_resolver: None,
467            metrics: Metrics::new("dd_traces_relay", "relays"),
468        })
469    }
470
471    /// Resolves every process to the same container.
472    struct FixedContainerResolver;
473
474    impl CaptureEntityResolver for FixedContainerResolver {
475        fn resolve_container_entity_for_live_pid(&self, _process_id: u32) -> Option<EntityId> {
476            Some(EntityId::Container("tracer-container".into()))
477        }
478    }
479
480    /// Builds a request as the relay's server hands it over: with the address of the peer that sent it.
481    fn request_from(peer: ConnectionAddress, headers: &[(&str, &str)]) -> AxumRequest {
482        let mut builder = Request::builder().method("POST").uri("/v0.4/traces");
483        for (name, value) in headers {
484            builder = builder.header(*name, *value);
485        }
486        let mut request = builder.body(axum::body::Body::empty()).unwrap();
487        request.extensions_mut().insert(ConnectInfo(peer));
488        request
489    }
490
491    fn unix_peer() -> ConnectionAddress {
492        ProcessCredentials {
493            pid: 1234,
494            uid: 0,
495            gid: 0,
496        }
497        .into()
498    }
499
500    #[test]
501    fn sets_the_container_id_of_a_unix_socket_peer() {
502        let mut request = request_from(unix_peer(), &[]);
503
504        add_peer_container_id(&mut request, &FixedContainerResolver);
505
506        assert_eq!(request.headers()[CONTAINER_ID_HEADER], "tracer-container");
507    }
508
509    #[test]
510    fn keeps_the_container_headers_a_tracer_sent() {
511        let mut request = request_from(unix_peer(), &[("Datadog-Container-ID", "from-tracer")]);
512        add_peer_container_id(&mut request, &FixedContainerResolver);
513        assert_eq!(request.headers()[CONTAINER_ID_HEADER], "from-tracer");
514
515        let mut request = request_from(unix_peer(), &[("Datadog-Entity-ID", "in-42")]);
516        add_peer_container_id(&mut request, &FixedContainerResolver);
517        assert!(!request.headers().contains_key(CONTAINER_ID_HEADER));
518    }
519
520    #[test]
521    fn leaves_tcp_requests_alone() {
522        let mut request = request_from(ConnectionAddress::from(SocketAddr::from(([127, 0, 0, 1], 4242))), &[]);
523
524        add_peer_container_id(&mut request, &FixedContainerResolver);
525
526        assert!(!request.headers().contains_key(CONTAINER_ID_HEADER));
527    }
528
529    #[tokio::test]
530    async fn counts_a_request_the_trace_agent_never_received_as_failed() {
531        let recorder = saluki_metrics::test::TestRecorder::default();
532        let _recorder_guard = metrics::set_default_local_recorder(&recorder);
533        let router = test_router();
534
535        let response = router
536            .oneshot(
537                Request::builder()
538                    .uri("/v0.4/traces")
539                    .body(axum::body::Body::empty())
540                    .unwrap(),
541            )
542            .await
543            .unwrap();
544        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
545
546        let metric_key = |name| {
547            metrics::Key::from_parts(
548                name,
549                vec![
550                    metrics::Label::new("component_id", "dd_traces_relay"),
551                    metrics::Label::new("component_type", "relays"),
552                ],
553            )
554        };
555        assert_eq!(recorder.counter(metric_key("component_requests_failed_total")), Some(1));
556        assert_eq!(
557            recorder.counter(metric_key("component_requests_relayed_total")),
558            Some(0)
559        );
560    }
561
562    #[tokio::test]
563    async fn proxies_requests_to_the_trace_agent() {
564        let router = test_router();
565
566        let response = router
567            .oneshot(
568                Request::builder()
569                    .method("POST")
570                    .uri("/not-a-real-path")
571                    .body(axum::body::Body::empty())
572                    .unwrap(),
573            )
574            .await
575            .unwrap();
576
577        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
578    }
579
580    #[tokio::test]
581    async fn binds_and_serves_over_tcp() {
582        const BOUND_ADDRESS_ID: &str = "apm-relay-tcp-test-bound-address";
583
584        let supervisor = TestSupervisor::start("apm-relay-tcp-test").await;
585
586        // Bind to an OS-assigned ephemeral port, and discover it via the published `BoundListenAddress`.
587        let tcp_endpoint = ListenAddress::try_from("tcp://127.0.0.1:0").expect("TCP APM endpoint should parse");
588
589        let router = test_router();
590        let server = HttpServer::from_listen_address(tcp_endpoint)
591            .with_routes(router)
592            .with_server_id(BOUND_ADDRESS_ID)
593            .with_worker_pool(tokio::runtime::Handle::current());
594
595        let mut bound_address_subscription = supervisor
596            .dataspace()
597            .subscribe::<BoundListenAddress>(IdentifierFilter::exact(format!("http-server-{BOUND_ADDRESS_ID}")));
598
599        supervisor
600            .scope(async {
601                runtime::nested_supervisor(server.into_supervisor()).spawn();
602            })
603            .await;
604
605        let bound_addr = match tokio::time::timeout(Duration::from_secs(5), bound_address_subscription.recv()).await {
606            Ok(Some(DataspaceUpdate::Asserted(_, BoundListenAddress::Tcp(address)))) => address,
607            update => panic!("expected a bound address assertion for `{BOUND_ADDRESS_ID}`, got {update:?}"),
608        };
609
610        let mut stream = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(bound_addr))
611            .await
612            .expect("should connect to TCP listener within the timeout")
613            .expect("should connect to TCP listener");
614
615        let body = b"proxied request over tcp";
616        let request = format!(
617            "POST /info HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
618            body.len()
619        );
620        stream
621            .write_all(request.as_bytes())
622            .await
623            .expect("should write request head");
624        stream.write_all(body).await.expect("should write request body");
625
626        let mut response = Vec::new();
627        tokio::time::timeout(Duration::from_secs(5), stream.read_to_end(&mut response))
628            .await
629            .expect("server should respond over TCP")
630            .expect("should read response");
631
632        assert!(
633            response.starts_with(b"HTTP/1.1 502"),
634            "expected a 502 Bad Gateway response, got: {}",
635            String::from_utf8_lossy(&response)
636        );
637    }
638
639    #[tokio::test]
640    async fn answers_a_request_proxied_back_to_itself_with_loop_detected() {
641        const BOUND_ADDRESS_ID: &str = "apm-relay-loop-test-bound-address";
642
643        let supervisor = TestSupervisor::start("apm-relay-loop-test").await;
644
645        // Find a free port, then point both the listener and the destination at it. The relay is served directly
646        // rather than built, as building rejects this configuration.
647        let port = std::net::TcpListener::bind("127.0.0.1:0")
648            .and_then(|listener| listener.local_addr())
649            .expect("should find a free port")
650            .port();
651        let tcp_endpoint =
652            ListenAddress::try_from(format!("tcp://127.0.0.1:{port}")).expect("TCP APM endpoint should parse");
653        let router = build_router(RelayState {
654            proxy: TraceAgentProxy::from_destination(&format!("http://127.0.0.1:{port}"))
655                .expect("destination should parse"),
656            capture_entity_resolver: None,
657            metrics: Metrics::new("dd_traces_relay", "relays"),
658        });
659        let server = HttpServer::from_listen_address(tcp_endpoint)
660            .with_routes(router)
661            .with_server_id(BOUND_ADDRESS_ID)
662            .with_worker_pool(tokio::runtime::Handle::current());
663
664        let mut bound_address_subscription = supervisor
665            .dataspace()
666            .subscribe::<BoundListenAddress>(IdentifierFilter::exact(format!("http-server-{BOUND_ADDRESS_ID}")));
667
668        supervisor
669            .scope(async {
670                runtime::nested_supervisor(server.into_supervisor()).spawn();
671            })
672            .await;
673
674        let bound_addr = match tokio::time::timeout(Duration::from_secs(5), bound_address_subscription.recv()).await {
675            Ok(Some(DataspaceUpdate::Asserted(_, BoundListenAddress::Tcp(address)))) => address,
676            update => panic!("expected a bound address assertion for `{BOUND_ADDRESS_ID}`, got {update:?}"),
677        };
678
679        let mut stream = TcpStream::connect(bound_addr)
680            .await
681            .expect("should connect to TCP listener");
682        stream
683            .write_all(b"GET /info HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
684            .await
685            .expect("should write request");
686
687        let mut response = Vec::new();
688        tokio::time::timeout(Duration::from_secs(5), stream.read_to_end(&mut response))
689            .await
690            .expect("the relay should answer rather than proxy the request to itself forever")
691            .expect("should read response");
692
693        assert!(
694            response.starts_with(b"HTTP/1.1 508"),
695            "expected a 508 Loop Detected response, got: {}",
696            String::from_utf8_lossy(&response)
697        );
698    }
699
700    #[cfg(unix)]
701    #[tokio::test]
702    async fn binds_and_serves_over_unix_domain_socket() {
703        const BOUND_ADDRESS_ID: &str = "apm-relay-uds-test-bound-address";
704
705        let supervisor = TestSupervisor::start("apm-relay-uds-test").await;
706
707        let dir = tempdir().expect("temp dir should be created");
708        let socket_path = dir.path().join("apm.sock");
709        let uds_endpoint = ListenAddress::try_from(format!("unix://{}", socket_path.display()).as_str())
710            .expect("Unix APM endpoint should parse");
711
712        let router = test_router();
713        let server = HttpServer::from_listen_address(uds_endpoint)
714            .with_routes(router)
715            .with_server_id(BOUND_ADDRESS_ID)
716            .with_worker_pool(tokio::runtime::Handle::current());
717
718        // The bound address is published once the listener accepts connections.
719        let mut bound_address_subscription = supervisor
720            .dataspace()
721            .subscribe::<BoundListenAddress>(IdentifierFilter::exact(format!("http-server-{BOUND_ADDRESS_ID}")));
722
723        supervisor
724            .scope(async {
725                runtime::nested_supervisor(server.into_supervisor()).spawn();
726            })
727            .await;
728
729        let bound_path = match tokio::time::timeout(Duration::from_secs(5), bound_address_subscription.recv()).await {
730            Ok(Some(DataspaceUpdate::Asserted(_, BoundListenAddress::Unix(path)))) => path,
731            update => panic!("expected a bound address assertion for `{BOUND_ADDRESS_ID}`, got {update:?}"),
732        };
733        assert_eq!(bound_path, socket_path);
734
735        let mut stream = UnixStream::connect(&socket_path)
736            .await
737            .expect("should connect to Unix domain socket");
738
739        let body = b"proxied request over uds";
740        let request = format!(
741            "POST /info HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
742            body.len()
743        );
744        stream
745            .write_all(request.as_bytes())
746            .await
747            .expect("should write request head");
748        stream.write_all(body).await.expect("should write request body");
749
750        let mut response = Vec::new();
751        tokio::time::timeout(Duration::from_secs(5), stream.read_to_end(&mut response))
752            .await
753            .expect("server should respond over the Unix domain socket")
754            .expect("should read response");
755
756        assert!(
757            response.starts_with(b"HTTP/1.1 502"),
758            "expected a 502 Bad Gateway response, got: {}",
759            String::from_utf8_lossy(&response)
760        );
761    }
762
763    fn test_config(bind_host: Option<&str>, non_local_traffic: bool, receiver_port: u16) -> ApmRelayConfiguration {
764        ApmRelayConfiguration {
765            receiver_socket: String::new(),
766            proxy_destination: "http://127.0.0.1:8127".to_string(),
767            capture_entity_resolver: None,
768            bind_host: bind_host.map(str::to_string),
769            non_local_traffic,
770            receiver_port,
771        }
772    }
773
774    #[tokio::test]
775    async fn a_listener_that_fails_to_bind_fails_the_relay() {
776        // Like a trace-agent that still owns the receiver port.
777        let occupied = std::net::TcpListener::bind("127.0.0.1:0").expect("should bind an ephemeral port");
778        let port = occupied.local_addr().expect("should have a local address").port();
779
780        let (control, _outputs) = TestComponentDriver::relay(test_config(None, false, port))
781            .await
782            .expect("the relay should build");
783
784        let result = control.wait().await;
785        assert!(result.is_err(), "the relay should fail when its listener can't bind");
786    }
787
788    async fn build_error(config: ApmRelayConfiguration) -> GenericError {
789        match config.build(BuildContext::test_relay("apm_relay_build_error")).await {
790            Ok(_) => panic!("build should fail"),
791            Err(error) => error,
792        }
793    }
794
795    #[tokio::test]
796    async fn build_fails_when_the_destination_is_the_tcp_listener() {
797        // The trace-agent's default port used for both the relay and its destination.
798        let config = test_config(None, false, 8127);
799        assert!(build_error(config).await.to_string().contains("relay's own listener"));
800
801        // A listener on every interface is also reached through the loopback address.
802        let config = test_config(None, true, 8127);
803        assert!(build_error(config).await.to_string().contains("relay's own listener"));
804
805        let mut config = test_config(None, false, 8127);
806        config.proxy_destination = "http://localhost:8127".to_string();
807        assert!(build_error(config).await.to_string().contains("relay's own listener"));
808    }
809
810    #[cfg(unix)]
811    #[tokio::test]
812    async fn build_fails_when_the_destination_is_the_unix_domain_socket_listener() {
813        let mut config = test_config(None, false, 0);
814        config.receiver_socket = "/var/run/datadog/apm.socket".to_string();
815        config.proxy_destination = "unix:///var/run/datadog/apm.socket".to_string();
816
817        assert!(build_error(config).await.to_string().contains("relay's own listener"));
818    }
819
820    #[test]
821    fn tcp_reaches_only_a_listener_on_the_same_port_and_address() {
822        let addr = |s: &str| s.parse::<SocketAddr>().unwrap();
823
824        assert!(tcp_reaches(addr("127.0.0.1:8126"), addr("127.0.0.1:8126")));
825        assert!(tcp_reaches(addr("127.0.0.1:8126"), addr("0.0.0.0:8126")));
826        assert!(tcp_reaches(addr("0.0.0.0:8126"), addr("127.0.0.1:8126")));
827        assert!(tcp_reaches(addr("[::1]:8126"), addr("[::]:8126")));
828
829        assert!(!tcp_reaches(addr("127.0.0.1:8127"), addr("127.0.0.1:8126")));
830        assert!(!tcp_reaches(addr("10.0.0.2:8126"), addr("10.0.0.1:8126")));
831        assert!(!tcp_reaches(addr("[::1]:8126"), addr("0.0.0.0:8126")));
832        assert!(!tcp_reaches(addr("127.0.0.1:8126"), addr("10.0.0.1:8126")));
833    }
834
835    #[test]
836    fn listen_host_follows_explicit_settings() {
837        let host = Some("10.0.0.1".to_string());
838
839        // An explicit `bind_host` applies, in a container or not.
840        for containerized in [false, true] {
841            assert_eq!(
842                resolve_listen_host(
843                    &ConfigValue::explicit(host.clone()),
844                    ConfigValue::defaulted(false),
845                    containerized
846                ),
847                (host.clone(), false)
848            );
849        }
850
851        // `apm_non_local_traffic` overrides it, even at its default value once `bind_host` is explicit, as in
852        // Kubernetes where it defaults to `true`.
853        assert_eq!(
854            resolve_listen_host(
855                &ConfigValue::explicit(host.clone()),
856                ConfigValue::defaulted(true),
857                false
858            ),
859            (host.clone(), true)
860        );
861
862        // An explicit `apm_non_local_traffic: false` keeps a container on the default host.
863        assert_eq!(
864            resolve_listen_host(
865                &ConfigValue::defaulted(Some("localhost".to_string())),
866                ConfigValue::explicit(false),
867                true
868            ),
869            (None, false)
870        );
871    }
872
873    #[test]
874    fn listen_host_binds_every_interface_in_a_container_when_unset() {
875        let defaulted_host = ConfigValue::defaulted(Some("localhost".to_string()));
876
877        assert_eq!(
878            resolve_listen_host(&defaulted_host, ConfigValue::defaulted(false), true),
879            (None, true)
880        );
881        assert_eq!(
882            resolve_listen_host(&defaulted_host, ConfigValue::defaulted(false), false),
883            (None, false)
884        );
885    }
886
887    #[tokio::test]
888    async fn build_fails_without_any_configured_endpoint() {
889        // Listening nowhere is a configuration error, not a silent no-op: `build` must reject it.
890        let config = test_config(None, false, 0);
891
892        let error = match config.build(BuildContext::test_relay("apm_relay_no_endpoints")).await {
893            Ok(_) => panic!("build should fail when no endpoint is configured"),
894            Err(error) => error,
895        };
896        assert!(error.to_string().contains("at least one"));
897    }
898
899    #[tokio::test]
900    async fn build_succeeds_with_only_tcp_endpoint_configured() {
901        let config = test_config(None, true, 8126);
902
903        assert!(config
904            .build(BuildContext::test_relay("apm_relay_tcp_only"))
905            .await
906            .is_ok());
907    }
908
909    #[tokio::test]
910    async fn build_fails_with_invalid_proxy_destination() {
911        let mut config = test_config(None, false, 8126);
912        config.proxy_destination = "ftp://127.0.0.1:8127".to_string();
913
914        assert!(config
915            .build(BuildContext::test_relay("apm_relay_bad_destination"))
916            .await
917            .is_err());
918    }
919
920    #[tokio::test]
921    async fn build_fails_with_unresolvable_bind_host() {
922        let config = test_config(Some("not a valid host"), false, 8126);
923
924        let error = match config
925            .build(BuildContext::test_relay("apm_relay_unresolvable_bind_host"))
926            .await
927        {
928            Ok(_) => panic!("build should fail for an unresolvable bind host"),
929            Err(error) => error,
930        };
931        assert!(error.to_string().contains("Could not resolve bind_host"));
932    }
933
934    #[tokio::test]
935    async fn tcp_listen_address_defaults_to_loopback() {
936        let config = test_config(None, false, 8127);
937        assert_eq!(
938            config.tcp_listen_address().await.unwrap().unwrap().to_string(),
939            "tcp://127.0.0.1:8127"
940        );
941    }
942
943    #[tokio::test]
944    async fn tcp_listen_address_uses_bind_host() {
945        let config = test_config(Some("192.168.1.50"), false, 8127);
946        assert_eq!(
947            config.tcp_listen_address().await.unwrap().unwrap().to_string(),
948            "tcp://192.168.1.50:8127"
949        );
950    }
951
952    #[tokio::test]
953    async fn tcp_listen_address_non_local_traffic_overrides_bind_host() {
954        let config = test_config(Some("192.168.1.50"), true, 8127);
955        assert_eq!(
956            config.tcp_listen_address().await.unwrap().unwrap().to_string(),
957            "tcp://0.0.0.0:8127"
958        );
959    }
960
961    #[cfg(unix)]
962    #[test]
963    fn uds_listen_address_parses_path() {
964        let mut config = test_config(None, false, 0);
965        config.receiver_socket = "/tmp/apm.sock".to_string();
966        assert_eq!(
967            config.uds_listen_address().unwrap().unwrap().to_string(),
968            "unix:///tmp/apm.sock"
969        );
970    }
971}