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