Skip to main content

scion_stack/stack/
builder.rs

1// Copyright 2025 Anapaya Systems
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//   http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//! SCION stack builder.
15
16mod priority_connect;
17
18use std::{borrow::Cow, fmt, net, sync::Arc, time::Duration};
19
20use endhost_api_client::client::CrpcEndhostApiClient;
21use rand::seq::IndexedRandom;
22use reqwest_connect_rpc::{
23    client::CrpcClientError,
24    token_source::{TokenSource, static_token::StaticTokenSource},
25};
26use scion_sdk_utils::backoff::ExponentialBackoff;
27use url::Url;
28use x25519_dalek::StaticSecret;
29
30pub use crate::underlays::udp::{OutboundIpResolver, TargetAddrOutboundIpResolver};
31use crate::{
32    ea_source::{
33        EndhostApiSource, EndhostApiSourceError, StaticEndhostApiDiscovery, StaticEndhostApis,
34    },
35    path::fetcher::{EndhostApiSegmentFetcher, traits::SegmentFetcher},
36    stack::ScionStack,
37    underlays::{
38        SnapSocketConfig, UnderlayStack,
39        discovery::{PeriodicUnderlayDiscovery, UnderlayDiscovery},
40    },
41};
42
43const DEFAULT_UDP_NEXT_HOP_RESOLVER_FETCH_INTERVAL: Duration = Duration::from_secs(600);
44const DEFAULT_ENDHOST_API_DISCOVERY_MAX_GROUPS: usize = 5;
45const DEFAULT_ENDHOST_API_DISCOVERY_APIS_PER_GROUP: usize = 2;
46const DEFAULT_ENDHOST_API_DISCOVERY_PER_GROUP_DELAY: Duration = Duration::from_millis(500);
47
48/// Factory that builds the UDP underlay's outbound IP resolver from the selected endhost API URL.
49type OutboundIpResolverFactory = Box<dyn FnOnce(Url) -> Arc<dyn OutboundIpResolver> + Send>;
50
51/// Builder for creating a [`ScionStack`].
52///
53/// # Example
54///
55/// ```no_run
56/// use scion_stack::stack::builder::ScionStackBuilder;
57/// use url::Url;
58///
59/// async fn setup_scion_stack() {
60///     let control_plane_url: Url = "http://127.0.0.1:1234".parse().unwrap();
61///
62///     let scion_stack = ScionStackBuilder::new()
63///         .with_endhost_api(control_plane_url)
64///         .with_auth_token("snap_token".to_string())
65///         .build()
66///         .await
67///         .unwrap();
68/// }
69/// ```
70pub struct ScionStackBuilder {
71    crpc_client: Option<reqwest::Client>,
72    endhost_api_token_source: Option<Arc<dyn TokenSource>>,
73    auth_token_source: Option<Arc<dyn TokenSource>>,
74    endhost_api_source: Arc<dyn EndhostApiSource>,
75    preferred_underlay: PreferredUnderlay,
76    endhost_api_discovery: EndhostApiDiscoveryConfig,
77    snap: SnapUnderlayConfig,
78    udp: UdpUnderlayConfig,
79}
80
81impl ScionStackBuilder {
82    /// Create a new [`ScionStackBuilder`].
83    ///
84    /// The stack uses the the endhost API to discover the available data planes.
85    /// By default, udp dataplanes are preferred over snap dataplanes.
86    #[must_use]
87    pub fn new() -> Self {
88        Self {
89            crpc_client: None,
90            endhost_api_token_source: None,
91            auth_token_source: None,
92            endhost_api_source: Arc::new(StaticEndhostApiDiscovery::global()),
93            preferred_underlay: PreferredUnderlay::Udp,
94            endhost_api_discovery: EndhostApiDiscoveryConfig::default(),
95            snap: SnapUnderlayConfig::default(),
96            udp: UdpUnderlayConfig::default(),
97        }
98    }
99
100    /// Sets which underlay to prefer when discovering data planes, if both are available.
101    ///
102    /// Defaults to [`PreferredUnderlay::Udp`].
103    #[must_use]
104    pub fn with_preferred_underlay(mut self, preferred: PreferredUnderlay) -> Self {
105        self.preferred_underlay = preferred;
106        self
107    }
108
109    /// Set a custom CRPC client for discovering and connecting to data planes.
110    ///
111    /// Can be useful if no DNS resolution is possible, so the client can be configured with custom
112    /// name resolution or with IP addresses directly.
113    #[must_use]
114    pub fn with_crpc_client(mut self, crpc_client: reqwest::Client) -> Self {
115        self.crpc_client = Some(crpc_client);
116        self
117    }
118
119    /// Set a static endhost API
120    ///
121    /// Replaces existing endhost API source.
122    ///
123    /// See [`Self::with_endhost_api_discovery_source`] for more flexible configuration
124    #[must_use]
125    pub fn with_endhost_api(mut self, endhost_api_url: Url) -> Self {
126        let source = StaticEndhostApis::new().add_group(vec![endhost_api_url]);
127        self.endhost_api_source = Arc::new(source);
128
129        self
130    }
131
132    /// Sets how the client will find its endhost APIs.
133    ///
134    /// If none is set, the stack will fall back to using the global discovery API.
135    #[must_use]
136    pub fn with_endhost_api_discovery_source(mut self, source: impl EndhostApiSource) -> Self {
137        self.endhost_api_source = Arc::new(source);
138        self
139    }
140
141    /// Set a token source to use for authentication with the endhost API.
142    #[must_use]
143    pub fn with_endhost_api_auth_token_source(mut self, source: impl TokenSource) -> Self {
144        self.endhost_api_token_source = Some(Arc::new(source));
145        self
146    }
147
148    /// Set a static token to use for authentication with the endhost API.
149    #[must_use]
150    pub fn with_endhost_api_auth_token(mut self, token: String) -> Self {
151        self.endhost_api_token_source = Some(Arc::new(StaticTokenSource::from(token)));
152        self
153    }
154
155    /// Set a token source to use for authentication both with the endhost API and the SNAP control
156    /// plane.
157    /// If a more specific token source is set, it takes precedence over this token source.
158    #[must_use]
159    pub fn with_auth_token_source(mut self, source: impl TokenSource) -> Self {
160        self.auth_token_source = Some(Arc::new(source));
161        self
162    }
163
164    /// Set a static token to use for authentication both with the endhost API and the SNAP control
165    /// plane.
166    /// If a more specific token is set, it takes precedence over this token.
167    #[must_use]
168    pub fn with_auth_token(mut self, token: String) -> Self {
169        self.auth_token_source = Some(Arc::new(StaticTokenSource::from(token)));
170        self
171    }
172
173    /// Set the maximum number of API groups to probe during endhost API
174    /// discovery.
175    ///
176    /// Groups are ordered by priority; only the first `max_groups` non-empty
177    /// groups returned by the discovery source are considered. Defaults to 5.
178    #[must_use]
179    pub fn with_endhost_api_discovery_max_groups(mut self, max_groups: usize) -> Self {
180        self.endhost_api_discovery.max_groups = max_groups;
181        self
182    }
183
184    /// Set the maximum number of APIs to probe per group during endhost API
185    /// discovery.
186    ///
187    /// APIs are selected at random within each group. Setting this to a higher
188    /// value increases redundancy at the cost of additional concurrent
189    /// connections. Defaults to 2.
190    #[must_use]
191    pub fn with_anapaya_ead_apis_per_group(mut self, apis_per_group: usize) -> Self {
192        self.endhost_api_discovery.apis_per_group = apis_per_group;
193        self
194    }
195
196    /// Set the delay before APIs in group `k` begin connecting, measured from
197    /// the start of discovery.
198    ///
199    /// Group `k` starts after `k × per_group_delay` **or** as soon as group
200    /// `k-1` is fully exhausted, whichever comes first. A shorter delay reduces
201    /// time-to-connect when a high-priority group is slow, at the cost of
202    /// additional concurrent connections to lower-priority groups. Defaults to
203    /// 500 ms.
204    #[must_use]
205    pub fn with_endhost_api_discovery_per_group_delay(mut self, per_group_delay: Duration) -> Self {
206        self.endhost_api_discovery.per_group_delay = per_group_delay;
207        self
208    }
209
210    /// Set SNAP underlay specific configuration for the SCION stack.
211    #[must_use]
212    pub fn with_snap_underlay_config(mut self, config: SnapUnderlayConfig) -> Self {
213        self.snap = config;
214        self
215    }
216
217    /// Set UDP underlay specific configuration for the SCION stack.
218    #[must_use]
219    pub fn with_udp_underlay_config(mut self, config: UdpUnderlayConfig) -> Self {
220        self.udp = config;
221        self
222    }
223
224    /// Build the SCION stack.
225    ///
226    /// # Returns
227    ///
228    /// A new SCION stack.
229    pub async fn build(self) -> Result<ScionStack, BuildScionStackError> {
230        let ScionStackBuilder {
231            crpc_client,
232            endhost_api_token_source,
233            auth_token_source,
234            endhost_api_source,
235            preferred_underlay,
236            endhost_api_discovery,
237            snap,
238            udp,
239        } = self;
240
241        // Race a random sample of APIs from each of the first N groups,
242        // staggered by group priority. Group k starts after k *
243        // per_group_delay or when group k-1 is fully exhausted, whichever
244        // comes first.
245        let api_groups = endhost_api_source.endhost_apis().await?;
246        let api_groups: Vec<Vec<Url>> = {
247            let mut rng = rand::rng();
248            api_groups
249                .into_iter()
250                .map(|g| g.apis.into_iter().map(|a| a.address).collect::<Vec<_>>())
251                .filter(|group| !group.is_empty())
252                .take(endhost_api_discovery.max_groups)
253                .map(|group: Vec<Url>| {
254                    group
255                        .sample(&mut rng, endhost_api_discovery.apis_per_group)
256                        .cloned()
257                        .collect()
258                })
259                .collect()
260        };
261
262        if api_groups.is_empty() {
263            // Likely not transient, since it indicates a misconfiguration on client or server side.
264            return Err(BuildScionStackError::EndhostApiSourceError(
265                EndhostApiSourceError::new("endhost API discovery returned no APIs", false),
266            ));
267        }
268
269        let token_source: Option<Arc<dyn TokenSource>> =
270            endhost_api_token_source.or(auth_token_source.clone());
271        let crpc_c = crpc_client.clone();
272        let discover_underlays = move |url: Url| {
273            let token_source = token_source.clone();
274            let crpc_c = crpc_c.clone();
275            let url = url.clone();
276            async move {
277                let mut client = match crpc_c {
278                    Some(client) => {
279                        CrpcEndhostApiClient::new_with_client(&url, client)
280                            .map_err(ApiAttemptError::client_setup)?
281                    }
282                    None => {
283                        CrpcEndhostApiClient::new(&url).map_err(ApiAttemptError::client_setup)?
284                    }
285                };
286                if let Some(token_source) = &token_source {
287                    client.use_token_source(token_source.clone());
288                }
289                let client = Arc::new(client);
290                let discovery = PeriodicUnderlayDiscovery::new(
291                    client.clone(),
292                    udp.udp_next_hop_resolver_fetch_interval,
293                    ExponentialBackoff::new(0.5, 10.0, 2.0, 0.5),
294                )
295                .await
296                .map_err(ApiAttemptError::underlay_discovery)?;
297                Ok((client, discovery))
298            }
299        };
300
301        let (api_url, (endhost_api_client, underlay_discovery)) =
302            priority_connect::try_priority_groups(
303                api_groups,
304                discover_underlays,
305                endhost_api_discovery.per_group_delay,
306            )
307            .await
308            .map_err(|errors| {
309                BuildScionStackError::AllEndhostApisFailed(AllEndhostApisFailed::new(errors))
310            })?;
311        tracing::info!(url=%api_url, "Selected endhost API");
312
313        // Resolve the outbound IP addresses for the UDP underlay sockets.
314        // By default we assume that the interface used to reach the endhost API is the same as
315        // the interface used to reach the data planes.
316        let outbound_ip_resolver: Arc<dyn OutboundIpResolver> =
317            (udp.outbound_ip_resolver_factory)(api_url.clone());
318
319        let underlay_stack = UnderlayStack::new(
320            preferred_underlay,
321            Arc::new(underlay_discovery),
322            outbound_ip_resolver,
323            snap.static_identity.unwrap_or_else(StaticSecret::random),
324            SnapSocketConfig {
325                crpc_client: snap.crpc_client.or(crpc_client),
326                snap_token_source: snap.snap_token_source.or(auth_token_source),
327            },
328        );
329
330        Ok(ScionStack::new(
331            Some(api_url),
332            Arc::new(EndhostApiSegmentFetcher::new(endhost_api_client)),
333            Arc::new(underlay_stack),
334        ))
335    }
336
337    /// Build a UDP-underlay SCION stack without endhost API.
338    ///
339    /// Unlike [`Self::build`], this performs no endhost-API discovery and contacts no endhost API
340    /// at runtime. The caller supplies everything the stack would otherwise obtain from an
341    /// endhost API:
342    ///
343    /// * `underlay_discovery` — the underlay topology, only UDP underlay is supported.
344    /// * `outbound_ip_resolver` — the outbound IP resolver.
345    /// * `default_segment_fetcher` — the path-segment source registered as the stack's default
346    ///   [`SegmentFetcher`]. It is consulted by every socket unless that socket opts out via
347    ///   [`crate::stack::SocketConfig::disable_default_segment_fetcher`].
348    ///
349    /// The resulting stack uses the UDP underlay only (no SNAP) and a freshly generated static
350    /// identity.
351    fn build_static_udp_underlay(
352        underlay_discovery: Arc<dyn UnderlayDiscovery>,
353        outbound_ip_resolver: Arc<dyn OutboundIpResolver>,
354        default_segment_fetcher: Arc<dyn SegmentFetcher>,
355    ) -> ScionStack {
356        let underlay_stack = UnderlayStack::new(
357            PreferredUnderlay::Udp,
358            underlay_discovery,
359            outbound_ip_resolver,
360            StaticSecret::random(),
361            SnapSocketConfig {
362                crpc_client: None,
363                snap_token_source: None,
364            },
365        );
366
367        ScionStack::new(None, default_segment_fetcher, Arc::new(underlay_stack))
368    }
369}
370
371impl Default for ScionStackBuilder {
372    fn default() -> Self {
373        Self::new()
374    }
375}
376
377impl ScionStack {
378    /// Builds a UDP-underlay SCION stack without an endhost API.
379    ///
380    /// Unlike [`ScionStackBuilder::build`], this performs no endhost-API discovery and contacts no
381    /// endhost API at runtime. The caller supplies everything the stack would otherwise obtain from
382    /// an endhost API:
383    ///
384    /// * `underlay_discovery` — the underlay topology, only UDP underlay is supported.
385    /// * `outbound_ip_resolver` — the outbound IP resolver.
386    /// * `default_segment_fetcher` — the path-segment source registered as the stack's default
387    ///   [`SegmentFetcher`]. It is consulted by every socket unless that socket opts out via
388    ///   [`crate::stack::SocketConfig::disable_default_segment_fetcher`].
389    ///
390    /// The resulting stack uses the UDP underlay only (no SNAP) and a freshly generated static
391    /// identity.
392    #[must_use]
393    pub fn static_udp_underlay(
394        underlay_discovery: Arc<dyn UnderlayDiscovery>,
395        outbound_ip_resolver: Arc<dyn OutboundIpResolver>,
396        default_segment_fetcher: Arc<dyn SegmentFetcher>,
397    ) -> ScionStack {
398        ScionStackBuilder::build_static_udp_underlay(
399            underlay_discovery,
400            outbound_ip_resolver,
401            default_segment_fetcher,
402        )
403    }
404}
405
406/// Build SCION stack errors.
407#[derive(thiserror::Error, Debug)]
408#[non_exhaustive]
409pub enum BuildScionStackError {
410    /// Discovery returned no underlay or no underlay was provided.
411    #[error("no underlay available: {0}")]
412    UnderlayUnavailable(Cow<'static, str>),
413    /// All endhost APIs failed during client setup or underlay discovery.
414    #[error(transparent)]
415    AllEndhostApisFailed(#[from] AllEndhostApisFailed),
416    /// Failed to retrieve any endhost APIs from the discovery source.
417    #[error(transparent)]
418    EndhostApiSourceError(#[from] EndhostApiSourceError),
419    /// Error building the SNAP SCION stack.
420    /// This error is only returned if a SNAP underlay is used.
421    #[error(transparent)]
422    Snap(#[from] BuildSnapScionStackError),
423    /// Internal error, this should never happen.
424    #[error("internal error")]
425    Internal(#[source] Box<dyn std::error::Error + Send + Sync>),
426}
427
428/// Build SNAP SCION stack errors.
429///
430/// The underlying cause of the client/discovery variants is available through
431/// [`std::error::Error::source`]; the concrete source types are intentionally not exposed.
432#[derive(thiserror::Error, Debug)]
433#[non_exhaustive]
434pub enum BuildSnapScionStackError {
435    /// Discovery returned no SNAP data plane.
436    #[error("no SNAP data plane available: {0}")]
437    DataPlaneUnavailable(Cow<'static, str>),
438    /// Error setting up the SNAP control plane client.
439    #[error("control plane client setup error")]
440    ControlPlaneClientSetup(#[source] Box<dyn std::error::Error + Send + Sync>),
441    /// Error making the data plane discovery request to the SNAP control plane.
442    #[error("data plane discovery request error")]
443    DataPlaneDiscovery(#[source] Box<dyn std::error::Error + Send + Sync>),
444}
445
446/// Error returned when every attempted endhost API fails.
447///
448/// Formats as a single-line summary suitable for use in structured logs.
449#[derive(Debug)]
450pub struct AllEndhostApisFailed {
451    failures: Vec<(Url, ApiAttemptError)>,
452}
453
454impl AllEndhostApisFailed {
455    pub(crate) fn new(failures: Vec<(Url, ApiAttemptError)>) -> Self {
456        Self { failures }
457    }
458
459    /// The per-API failures, in the order the APIs were attempted.
460    #[must_use]
461    pub fn failures(&self) -> &[(Url, ApiAttemptError)] {
462        &self.failures
463    }
464
465    /// Returns whether every attempt failed for a transient reason (e.g. a connection error), so
466    /// building the stack may succeed on retry.
467    #[must_use]
468    pub fn is_transient(&self) -> bool {
469        !self.failures.is_empty() && self.failures.iter().all(|(_, err)| err.is_transient())
470    }
471}
472
473impl fmt::Display for AllEndhostApisFailed {
474    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
475        write!(f, "all {} endhost API(s) failed", self.failures.len())?;
476        let mut sep = ": ";
477        for (url, err) in &self.failures {
478            write!(f, "{sep}{url} ({err})")?;
479            sep = "; ";
480        }
481        Ok(())
482    }
483}
484
485impl std::error::Error for AllEndhostApisFailed {}
486
487/// Error for a single endhost API connection attempt.
488///
489/// The underlying cause is available through [`std::error::Error::source`]; the concrete source
490/// type is intentionally not exposed. Use [`is_transient`](ApiAttemptError::is_transient) to decide
491/// whether a retry may help.
492#[derive(thiserror::Error, Debug)]
493#[non_exhaustive]
494pub enum ApiAttemptError {
495    /// The API client could not be instantiated (e.g. invalid URL scheme).
496    #[error("client setup")]
497    ClientSetup {
498        /// Whether the failure is transient and a retry may help.
499        transient: bool,
500        /// The underlying cause.
501        #[source]
502        source: Box<dyn std::error::Error + Send + Sync>,
503    },
504    /// Underlay discovery against the API failed (e.g. server unreachable).
505    #[error("underlay discovery")]
506    UnderlayDiscovery {
507        /// Whether the failure is transient and a retry may help.
508        transient: bool,
509        /// The underlying cause.
510        #[source]
511        source: Box<dyn std::error::Error + Send + Sync>,
512    },
513}
514
515impl ApiAttemptError {
516    pub(crate) fn client_setup(error: anyhow::Error) -> Self {
517        // Client setup failures (e.g. an invalid URL scheme) are configuration errors, not
518        // transient.
519        Self::ClientSetup {
520            transient: false,
521            source: error.into_boxed_dyn_error(),
522        }
523    }
524
525    pub(crate) fn underlay_discovery(error: CrpcClientError) -> Self {
526        Self::UnderlayDiscovery {
527            transient: is_transient_crpc_error(&error),
528            source: Box::new(error),
529        }
530    }
531
532    /// Returns whether the failure is transient and a retry may help.
533    #[must_use]
534    pub fn is_transient(&self) -> bool {
535        match self {
536            Self::ClientSetup { transient, .. } | Self::UnderlayDiscovery { transient, .. } => {
537                *transient
538            }
539        }
540    }
541}
542
543/// A CRPC error is considered transient if it stems from a connection-level failure that may
544/// succeed on retry.
545fn is_transient_crpc_error(error: &CrpcClientError) -> bool {
546    matches!(error, CrpcClientError::ConnectionError { .. })
547}
548
549/// Configuration for endhost API discovery during stack building.
550///
551/// Controls how many API groups and endpoints are probed in parallel, and
552/// how long to wait before falling through to the next priority group.
553pub struct EndhostApiDiscoveryConfig {
554    /// Maximum number of API groups to consider, in priority order.
555    max_groups: usize,
556    /// Maximum number of APIs to probe per group, selected at random.
557    apis_per_group: usize,
558    /// Delay before group `k` begins connecting (`k × per_group_delay`),
559    /// unless the previous group is exhausted sooner.
560    per_group_delay: Duration,
561}
562
563impl Default for EndhostApiDiscoveryConfig {
564    fn default() -> Self {
565        Self {
566            max_groups: DEFAULT_ENDHOST_API_DISCOVERY_MAX_GROUPS,
567            apis_per_group: DEFAULT_ENDHOST_API_DISCOVERY_APIS_PER_GROUP,
568            per_group_delay: DEFAULT_ENDHOST_API_DISCOVERY_PER_GROUP_DELAY,
569        }
570    }
571}
572
573/// Preferred underlay type (if available).
574#[derive(Debug, Clone, Copy, PartialEq, Eq)]
575#[non_exhaustive]
576pub enum PreferredUnderlay {
577    /// SNAP underlay.
578    Snap,
579    /// UDP underlay.
580    Udp,
581}
582
583/// SNAP underlay configuration.
584///
585/// Construct with [`SnapUnderlayConfig::default`] and customize with the consuming `with_*`
586/// methods, then pass to [`ScionStackBuilder::with_snap_underlay_config`].
587#[derive(Default)]
588pub struct SnapUnderlayConfig {
589    crpc_client: Option<reqwest::Client>,
590    snap_token_source: Option<Arc<dyn TokenSource>>,
591    snap_dp_index: usize,
592    /// Private key used for snap-tun connections. If unset, a random static identity is generated.
593    static_identity: Option<StaticSecret>,
594}
595
596impl SnapUnderlayConfig {
597    /// Sets a static token to use for authentication with the SNAP control plane.
598    #[must_use]
599    pub fn with_auth_token(mut self, token: String) -> Self {
600        self.snap_token_source = Some(Arc::new(StaticTokenSource::from(token)));
601        self
602    }
603
604    /// Sets a token source to use for authentication with the SNAP control plane.
605    #[must_use]
606    pub fn with_auth_token_source(mut self, source: impl TokenSource) -> Self {
607        self.snap_token_source = Some(Arc::new(source));
608        self
609    }
610
611    /// Sets a custom CRPC client for discovering and connecting to data planes.
612    #[must_use]
613    pub fn with_crpc_client(mut self, client: reqwest::Client) -> Self {
614        self.crpc_client = Some(client);
615        self
616    }
617
618    /// Sets the index of the SNAP data plane to use.
619    #[must_use]
620    pub fn with_snap_dp_index(mut self, dp_index: usize) -> Self {
621        self.snap_dp_index = dp_index;
622        self
623    }
624
625    /// Sets the static identity to use for snap-tun connections.
626    ///
627    /// If unset, a random static identity is generated.
628    #[must_use]
629    pub fn with_static_identity(mut self, identity: StaticSecret) -> Self {
630        self.static_identity = Some(identity);
631        self
632    }
633}
634
635/// UDP underlay configuration.
636///
637/// Construct with [`UdpUnderlayConfig::default`] and customize with the consuming `with_*` methods,
638/// then pass to [`ScionStackBuilder::with_udp_underlay_config`].
639pub struct UdpUnderlayConfig {
640    udp_next_hop_resolver_fetch_interval: Duration,
641    outbound_ip_resolver_factory: OutboundIpResolverFactory,
642}
643
644impl Default for UdpUnderlayConfig {
645    fn default() -> Self {
646        Self {
647            udp_next_hop_resolver_fetch_interval: DEFAULT_UDP_NEXT_HOP_RESOLVER_FETCH_INTERVAL,
648            outbound_ip_resolver_factory: Box::new(move |url| {
649                Arc::new(TargetAddrOutboundIpResolver::new(url, vec![]))
650            }),
651        }
652    }
653}
654
655impl UdpUnderlayConfig {
656    /// Sets the outbound IP addresses to use for the UDP underlay.
657    ///
658    /// If not set, the UDP underlay will use the local IP that can reach the endhost API.
659    /// This is a convenience wrapper around [`Self::with_outbound_ip_resolver`] for a fixed set of
660    /// addresses.
661    #[must_use]
662    pub fn with_outbound_ips(mut self, outbound_ips: Vec<net::IpAddr>) -> Self {
663        self.outbound_ip_resolver_factory =
664            Box::new(move |_url| Arc::new(outbound_ips) as Arc<dyn OutboundIpResolver>);
665        self
666    }
667
668    /// Sets a custom outbound IP resolver for the UDP underlay.
669    ///
670    /// Use this method when outbound IP resolution does not depend on the selected endhost API URL.
671    /// If the resolver needs the endhost API URL, use [`Self::with_outbound_ip_resolver_factory`]
672    /// instead.
673    ///
674    /// By default, [`TargetAddrOutboundIpResolver`] is used, which resolves the endhost API
675    /// hostname via OS DNS.
676    #[must_use]
677    pub fn with_outbound_ip_resolver(
678        mut self,
679        resolver: impl OutboundIpResolver + 'static,
680    ) -> Self {
681        let resolver = Arc::new(resolver) as Arc<dyn OutboundIpResolver>;
682        self.outbound_ip_resolver_factory = Box::new(move |_url| resolver.clone());
683        self
684    }
685
686    /// Sets a factory that builds the UDP underlay's outbound IP resolver from the selected endhost
687    /// API URL.
688    ///
689    /// The winning endhost API URL is only known once the stack connects during
690    /// [`ScionStackBuilder::build`], so resolvers that depend on it must be constructed via this
691    /// factory. The factory is invoked once with the selected URL.
692    ///
693    /// Use this when the hostname is only resolvable through a custom DNS override that is
694    /// invisible to the OS resolver, or to provide a fully custom URL-aware resolution
695    /// strategy. If the resolver does not need the URL, use [`Self::with_outbound_ip_resolver`]
696    /// instead.
697    #[must_use]
698    pub fn with_outbound_ip_resolver_factory<F, R>(mut self, factory: F) -> Self
699    where
700        F: FnOnce(Url) -> R + Send + 'static,
701        R: OutboundIpResolver + 'static,
702    {
703        self.outbound_ip_resolver_factory =
704            Box::new(move |url| Arc::new(factory(url)) as Arc<dyn OutboundIpResolver>);
705        self
706    }
707
708    /// Sets the interval at which the UDP next hop resolver fetches the next hops from the endhost
709    /// API.
710    #[must_use]
711    pub fn with_udp_next_hop_resolver_fetch_interval(mut self, fetch_interval: Duration) -> Self {
712        self.udp_next_hop_resolver_fetch_interval = fetch_interval;
713        self
714    }
715}
716
717#[cfg(test)]
718mod tests {
719    use std::borrow::Cow;
720
721    use reqwest_connect_rpc::client::CrpcClientError;
722    use url::Url;
723
724    use super::*;
725
726    fn connection_error() -> CrpcClientError {
727        CrpcClientError::ConnectionError {
728            context: Cow::Borrowed("test"),
729            source: Box::new(std::io::Error::other("boom")),
730        }
731    }
732
733    fn non_connection_error() -> CrpcClientError {
734        CrpcClientError::DecodeError {
735            context: Cow::Borrowed("test"),
736            source: Some(Box::new(std::io::Error::other("boom"))),
737            body: None,
738        }
739    }
740
741    #[test]
742    fn api_attempt_error_transient_classification() {
743        // A connection-level discovery failure is transient.
744        assert!(ApiAttemptError::underlay_discovery(connection_error()).is_transient());
745        // Any other discovery failure is not.
746        assert!(!ApiAttemptError::underlay_discovery(non_connection_error()).is_transient());
747        // Client setup failures are configuration errors, never transient.
748        assert!(!ApiAttemptError::client_setup(anyhow::anyhow!("invalid url")).is_transient());
749    }
750
751    #[test]
752    fn all_endhost_apis_failed_transient_classification() {
753        let url: Url = "http://example.com".parse().expect("valid url");
754
755        // An empty failure set is not transient (there was nothing to retry).
756        assert!(!AllEndhostApisFailed::new(vec![]).is_transient());
757
758        // All-transient failures are transient.
759        assert!(
760            AllEndhostApisFailed::new(vec![(
761                url.clone(),
762                ApiAttemptError::underlay_discovery(connection_error()),
763            )])
764            .is_transient()
765        );
766
767        // A single non-transient failure makes the whole set non-transient.
768        assert!(
769            !AllEndhostApisFailed::new(vec![
770                (
771                    url.clone(),
772                    ApiAttemptError::underlay_discovery(connection_error()),
773                ),
774                (
775                    url,
776                    ApiAttemptError::underlay_discovery(non_connection_error())
777                ),
778            ])
779            .is_transient()
780        );
781    }
782}