agent_data_plane_config/domains/apm.rs
1//! APM domain: the Datadog v1.0 (`idx`/ETP) trace receiver.
2//!
3//! This domain covers ingress only: where the receiver listens, how large a request it accepts, and
4//! whether it may bind a non-loopback address. Trace *processing* (obfuscation, sampling, stats) is
5//! shared with the OTLP path and lives in the [`traces`](super::traces) domain.
6//!
7//! Every key here is Saluki-only. The Datadog schema's `apm_config.receiver_port`,
8//! `apm_config.receiver_socket`, `apm_config.max_payload_size`, and
9//! `apm_config.apm_non_local_traffic` all sit in the excluded block of the overlay: they configure
10//! the trace-agent's receiver, which keeps running alongside ADP, so reusing them would create a
11//! duplicate source of truth for two listeners that must not collide.
12
13use std::net::{IpAddr, SocketAddr};
14use std::time::Duration;
15
16use serde::Serialize;
17
18use crate::defaults::{
19 DEFAULT_APM_DISPATCH_TIMEOUT, DEFAULT_APM_MAX_PAYLOAD_SIZE, DEFAULT_APM_NON_LOCAL_TRAFFIC,
20 DEFAULT_APM_RECEIVER_ENDPOINT,
21};
22use crate::Error;
23
24/// Host name that always denotes a loopback address, whichever family it resolves to.
25const LOCALHOST: &str = "localhost";
26
27/// Resolved APM configuration.
28#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
29pub struct Domain {
30 /// TCP address the v1.0 trace receiver listens on, as `host:port`.
31 ///
32 /// Defaults to `localhost:8127`. An empty value disables the TCP listener; when the Unix domain
33 /// socket is also disabled, enabling the APM pipeline is an error rather than a silent no-op.
34 /// Binding a non-loopback address additionally requires
35 /// [`non_local_traffic`](Self::non_local_traffic).
36 pub receiver_endpoint: String,
37
38 /// Unix domain socket path the v1.0 trace receiver listens on.
39 ///
40 /// Defaults to empty, which disables the Unix domain socket listener. Set this for deployments
41 /// where tracers share a filesystem with ADP but not a network namespace. Not subject to
42 /// [`non_local_traffic`](Self::non_local_traffic), which governs TCP only.
43 pub receiver_socket: String,
44
45 /// Maximum accepted v1.0 trace request body size, in bytes.
46 ///
47 /// Defaults to 25 MB, matching the reference trace-agent. Requests over this size are rejected
48 /// before any decoding, so this bounds peak memory per in-flight request. A value of `0` rejects
49 /// every request and is not a way to disable the limit.
50 pub max_payload_size: usize,
51
52 /// Whether the v1.0 trace receiver may bind a non-loopback TCP address.
53 ///
54 /// Defaults to `false`: a [`receiver_endpoint`](Self::receiver_endpoint) whose host is not a
55 /// loopback address is rejected at startup rather than quietly exposing the receiver. Set this to
56 /// `true` when tracers run outside ADP's network namespace, such as application containers
57 /// reaching an agent container, and the port is not reachable from untrusted networks.
58 pub non_local_traffic: bool,
59
60 /// How long the receiver waits for the pipeline to accept a payload before refusing it.
61 ///
62 /// Defaults to 1 second, matching the reference trace-agent's `apm_config.decoder_timeout`. The
63 /// receiver waits this long for memory-limiter capacity and for room in the queue feeding the
64 /// decoder; past it, the payload is dropped and the tracer gets a refusal, so a stalled pipeline
65 /// sheds load instead of holding tracer connections open indefinitely.
66 ///
67 /// Raise this to tolerate longer downstream stalls at the cost of slower refusals; lower it to
68 /// shed load sooner. A value of `0` refuses any payload the pipeline cannot accept immediately.
69 pub dispatch_timeout: Duration,
70}
71
72impl Default for Domain {
73 fn default() -> Self {
74 Self {
75 receiver_endpoint: DEFAULT_APM_RECEIVER_ENDPOINT.to_string(),
76 receiver_socket: String::new(),
77 max_payload_size: DEFAULT_APM_MAX_PAYLOAD_SIZE,
78 non_local_traffic: DEFAULT_APM_NON_LOCAL_TRAFFIC,
79 dispatch_timeout: DEFAULT_APM_DISPATCH_TIMEOUT,
80 }
81 }
82}
83
84impl Domain {
85 /// Validates [`receiver_endpoint`](Self::receiver_endpoint) against the non-local traffic gate.
86 ///
87 /// A disabled TCP listener, or an enabled [`non_local_traffic`](Self::non_local_traffic), permits
88 /// anything. Otherwise the endpoint's host must be a loopback address.
89 ///
90 /// Only the host is examined. A host that is neither `localhost` nor a literal IP address cannot
91 /// be shown to be loopback without resolving it, which startup validation does not do, so such a
92 /// host is rejected while the gate is off.
93 ///
94 /// # Errors
95 ///
96 /// Returns an error when the TCP listener is enabled, `non_local_traffic` is `false`, and the
97 /// configured host is not a loopback address.
98 pub fn validate_receiver_endpoint(&self) -> Result<(), Error> {
99 if self.non_local_traffic || self.receiver_endpoint.is_empty() {
100 return Ok(());
101 }
102
103 if endpoint_host_is_loopback(&self.receiver_endpoint) {
104 return Ok(());
105 }
106
107 Err(Error::new_without_source(format!(
108 "`data_plane.apm.receiver_endpoint` is `{}`, which is not a loopback address, but \
109 `data_plane.apm.non_local_traffic` is not set. Either bind a loopback address such as \
110 `{DEFAULT_APM_RECEIVER_ENDPOINT}`, or set `data_plane.apm.non_local_traffic` to `true` to accept \
111 traffic from outside this host.",
112 self.receiver_endpoint
113 )))
114 }
115}
116
117/// Returns whether the host portion of a `host:port` endpoint denotes a loopback address.
118///
119/// A bare IP literal (`127.0.0.1:8127`, `[::1]:8127`) and the name `localhost` are loopback. An empty
120/// host (`:8127`), a wildcard (`0.0.0.0:8127`), and any other name are not.
121fn endpoint_host_is_loopback(endpoint: &str) -> bool {
122 // A full socket address parses directly, which also handles the bracketed IPv6 form.
123 if let Ok(address) = endpoint.parse::<SocketAddr>() {
124 return address.ip().is_loopback();
125 }
126
127 let Some(host) = endpoint_host(endpoint) else {
128 return false;
129 };
130
131 if host.eq_ignore_ascii_case(LOCALHOST) {
132 return true;
133 }
134
135 host.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
136}
137
138/// Extracts the host portion of a `host:port` endpoint, unwrapping a bracketed IPv6 literal.
139///
140/// Returns `None` when the endpoint carries no host at all.
141fn endpoint_host(endpoint: &str) -> Option<&str> {
142 let host = if let Some(rest) = endpoint.strip_prefix('[') {
143 // Bracketed IPv6 literal: the host runs to the closing bracket, whatever follows it.
144 rest.split(']').next()?
145 } else {
146 // Split from the right so an unbracketed IPv6 literal, which is full of colons, is not
147 // mistaken for a `host:port` pair and truncated.
148 match endpoint.rsplit_once(':') {
149 Some((host, _port)) => host,
150 None => endpoint,
151 }
152 };
153
154 (!host.is_empty()).then_some(host)
155}
156
157#[cfg(test)]
158mod tests {
159 use super::{endpoint_host_is_loopback, Domain};
160 use crate::defaults::DEFAULT_APM_RECEIVER_ENDPOINT;
161
162 #[test]
163 fn the_default_endpoint_is_loopback() {
164 // The gate defaults to off, so the default endpoint has to pass its own validation.
165 assert!(endpoint_host_is_loopback(DEFAULT_APM_RECEIVER_ENDPOINT));
166 assert!(Domain::default().validate_receiver_endpoint().is_ok());
167 }
168
169 #[test]
170 fn loopback_hosts_are_recognized() {
171 for endpoint in [
172 "localhost:8127",
173 "LocalHost:8127",
174 "127.0.0.1:8127",
175 "127.9.9.9:8127",
176 "[::1]:8127",
177 "::1:8127",
178 ] {
179 assert!(endpoint_host_is_loopback(endpoint), "expected loopback: {endpoint:?}");
180 }
181 }
182
183 #[test]
184 fn non_loopback_hosts_are_rejected() {
185 // An unresolvable-at-startup name is treated as non-loopback: validation does not resolve.
186 for endpoint in [
187 "0.0.0.0:8127",
188 "[::]:8127",
189 ":8127",
190 "10.0.0.5:8127",
191 "trace-agent.internal:8127",
192 ] {
193 assert!(
194 !endpoint_host_is_loopback(endpoint),
195 "expected non-loopback: {endpoint:?}"
196 );
197 }
198 }
199
200 #[test]
201 fn a_non_loopback_endpoint_needs_the_non_local_traffic_gate() {
202 let mut domain = Domain {
203 receiver_endpoint: "0.0.0.0:8127".to_string(),
204 ..Default::default()
205 };
206
207 let error = domain
208 .validate_receiver_endpoint()
209 .expect_err("a wildcard bind must be rejected while the gate is off");
210 assert!(error.to_string().contains("non_local_traffic"));
211
212 domain.non_local_traffic = true;
213 assert!(domain.validate_receiver_endpoint().is_ok());
214 }
215
216 #[test]
217 fn a_disabled_tcp_listener_is_always_valid() {
218 // Nothing is bound, so the gate has nothing to protect.
219 let domain = Domain {
220 receiver_endpoint: String::new(),
221 receiver_socket: "/var/run/datadog/apm.socket".to_string(),
222 ..Default::default()
223 };
224
225 assert!(domain.validate_receiver_endpoint().is_ok());
226 }
227}