Skip to main content

scion_stack/scionstack/
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;
22pub use scion_sdk_reqwest_connect_rpc::client::CrpcClientError;
23use scion_sdk_reqwest_connect_rpc::token_source::{TokenSource, static_token::StaticTokenSource};
24use scion_sdk_utils::backoff::ExponentialBackoff;
25use url::Url;
26use x25519_dalek::StaticSecret;
27
28use crate::{
29    ea_source::{
30        EndhostApiSource, EndhostApiSourceError, StaticEndhostApiDiscovery, StaticEndhostApis,
31    },
32    scionstack::ScionStack,
33    underlays::{
34        SnapSocketConfig, UnderlayStack,
35        discovery::PeriodicUnderlayDiscovery,
36        udp::{LocalIpResolver, TargetAddrLocalIpResolver},
37    },
38};
39
40const DEFAULT_UDP_NEXT_HOP_RESOLVER_FETCH_INTERVAL: Duration = Duration::from_secs(600);
41const DEFAULT_ENDHOST_API_DISCOVERY_MAX_GROUPS: usize = 5;
42const DEFAULT_ENDHOST_API_DISCOVERY_APIS_PER_GROUP: usize = 2;
43const DEFAULT_ENDHOST_API_DISCOVERY_PER_GROUP_DELAY: Duration = Duration::from_millis(500);
44
45/// Builder for creating a [ScionStack].
46///
47/// # Example
48///
49/// ```no_run
50/// use scion_stack::scionstack::builder::ScionStackBuilder;
51/// use url::Url;
52///
53/// async fn setup_scion_stack() {
54///     let control_plane_url: Url = "http://127.0.0.1:1234".parse().unwrap();
55///
56///     let scion_stack = ScionStackBuilder::new()
57///         .with_endhost_api(control_plane_url)
58///         .with_auth_token("snap_token".to_string())
59///         .build()
60///         .await
61///         .unwrap();
62/// }
63/// ```
64pub struct ScionStackBuilder {
65    endhost_api_token_source: Option<Arc<dyn TokenSource>>,
66    auth_token_source: Option<Arc<dyn TokenSource>>,
67    endhost_api_source: Arc<dyn EndhostApiSource>,
68    preferred_underlay: PreferredUnderlay,
69    endhost_api_discovery: EndhostApiDiscoveryConfig,
70    snap: SnapUnderlayConfig,
71    udp: UdpUnderlayConfig,
72}
73
74impl ScionStackBuilder {
75    /// Create a new [ScionStackBuilder].
76    ///
77    /// The stack uses the the endhost API to discover the available data planes.
78    /// By default, udp dataplanes are preferred over snap dataplanes.
79    pub fn new() -> Self {
80        Self {
81            endhost_api_token_source: None,
82            auth_token_source: None,
83            endhost_api_source: Arc::new(StaticEndhostApiDiscovery::global()),
84            preferred_underlay: PreferredUnderlay::Udp,
85            endhost_api_discovery: EndhostApiDiscoveryConfig::default(),
86            snap: SnapUnderlayConfig::default(),
87            udp: UdpUnderlayConfig::default(),
88        }
89    }
90
91    /// When discovering data planes, prefer SNAP data planes if available.
92    pub fn with_prefer_snap(mut self) -> Self {
93        self.preferred_underlay = PreferredUnderlay::Snap;
94        self
95    }
96
97    /// When discovering data planes, prefer UDP data planes if available.
98    pub fn with_prefer_udp(mut self) -> Self {
99        self.preferred_underlay = PreferredUnderlay::Udp;
100        self
101    }
102
103    /// Set a static endhost API
104    ///
105    /// Replaces existing endhost API source.
106    ///
107    /// See [Self::with_endhost_api_discovery_source] for more flexible configuration
108    pub fn with_endhost_api(mut self, endhost_api_url: Url) -> Self {
109        let source = StaticEndhostApis::new().add_group(vec![endhost_api_url]);
110        self.endhost_api_source = Arc::new(source);
111
112        self
113    }
114
115    /// Sets how the client will find its endhost APIs.
116    ///
117    /// If none is set, the stack will fall back to using the global discovery API.
118    pub fn with_endhost_api_discovery_source(mut self, source: impl EndhostApiSource) -> Self {
119        self.endhost_api_source = Arc::new(source);
120        self
121    }
122
123    /// Set a token source to use for authentication with the endhost API.
124    pub fn with_endhost_api_auth_token_source(mut self, source: impl TokenSource) -> Self {
125        self.endhost_api_token_source = Some(Arc::new(source));
126        self
127    }
128
129    /// Set a static token to use for authentication with the endhost API.
130    pub fn with_endhost_api_auth_token(mut self, token: String) -> Self {
131        self.endhost_api_token_source = Some(Arc::new(StaticTokenSource::from(token)));
132        self
133    }
134
135    /// Set a token source to use for authentication both with the endhost API and the SNAP control
136    /// plane.
137    /// If a more specific token source is set, it takes precedence over this token source.
138    pub fn with_auth_token_source(mut self, source: impl TokenSource) -> Self {
139        self.auth_token_source = Some(Arc::new(source));
140        self
141    }
142
143    /// Set a static token to use for authentication both with the endhost API and the SNAP control
144    /// plane.
145    /// If a more specific token is set, it takes precedence over this token.
146    pub fn with_auth_token(mut self, token: String) -> Self {
147        self.auth_token_source = Some(Arc::new(StaticTokenSource::from(token)));
148        self
149    }
150
151    /// Set the maximum number of API groups to probe during endhost API
152    /// discovery.
153    ///
154    /// Groups are ordered by priority; only the first `max_groups` non-empty
155    /// groups returned by the discovery source are considered. Defaults to 5.
156    pub fn with_endhost_api_discovery_max_groups(mut self, max_groups: usize) -> Self {
157        self.endhost_api_discovery.max_groups = max_groups;
158        self
159    }
160
161    /// Set the maximum number of APIs to probe per group during endhost API
162    /// discovery.
163    ///
164    /// APIs are selected at random within each group. Setting this to a higher
165    /// value increases redundancy at the cost of additional concurrent
166    /// connections. Defaults to 2.
167    pub fn with_endhost_api_discovery_apis_per_group(mut self, apis_per_group: usize) -> Self {
168        self.endhost_api_discovery.apis_per_group = apis_per_group;
169        self
170    }
171
172    /// Set the delay before APIs in group `k` begin connecting, measured from
173    /// the start of discovery.
174    ///
175    /// Group `k` starts after `k × per_group_delay` **or** as soon as group
176    /// `k-1` is fully exhausted, whichever comes first. A shorter delay reduces
177    /// time-to-connect when a high-priority group is slow, at the cost of
178    /// additional concurrent connections to lower-priority groups. Defaults to
179    /// 500 ms.
180    pub fn with_endhost_api_discovery_per_group_delay(mut self, per_group_delay: Duration) -> Self {
181        self.endhost_api_discovery.per_group_delay = per_group_delay;
182        self
183    }
184
185    /// Set SNAP underlay specific configuration for the SCION stack.
186    pub fn with_snap_underlay_config(mut self, config: SnapUnderlayConfig) -> Self {
187        self.snap = config;
188        self
189    }
190
191    /// Set UDP underlay specific configuration for the SCION stack.
192    pub fn with_udp_underlay_config(mut self, config: UdpUnderlayConfig) -> Self {
193        self.udp = config;
194        self
195    }
196
197    /// Build the SCION stack.
198    ///
199    /// # Returns
200    ///
201    /// A new SCION stack.
202    pub async fn build(self) -> Result<ScionStack, BuildScionStackError> {
203        let ScionStackBuilder {
204            endhost_api_token_source,
205            auth_token_source,
206            endhost_api_source,
207            preferred_underlay,
208            endhost_api_discovery,
209            snap,
210            udp,
211        } = self;
212
213        // Race a random sample of APIs from each of the first N groups,
214        // staggered by group priority. Group k starts after k *
215        // per_group_delay or when group k-1 is fully exhausted, whichever
216        // comes first.
217        let api_groups = endhost_api_source.endhost_apis().await?;
218        let api_groups: Vec<Vec<Url>> = {
219            let mut rng = rand::rng();
220            api_groups
221                .into_iter()
222                .map(|g| g.apis.into_iter().map(|a| a.address).collect::<Vec<_>>())
223                .filter(|group| !group.is_empty())
224                .take(endhost_api_discovery.max_groups)
225                .map(|group: Vec<Url>| {
226                    group
227                        .sample(&mut rng, endhost_api_discovery.apis_per_group)
228                        .cloned()
229                        .collect()
230                })
231                .collect()
232        };
233
234        if api_groups.is_empty() {
235            return Err(BuildScionStackError::EndhostApiSourceError(
236                EndhostApiSourceError {
237                    error: anyhow::anyhow!("Endhost API discovery returned no APIs"),
238                    // Likely not transient, since it indicates a misconfiguration on client or
239                    // server side.
240                    transient: false,
241                },
242            ));
243        }
244
245        let token_source: Option<Arc<dyn TokenSource>> =
246            endhost_api_token_source.or(auth_token_source.clone());
247        let discover_underlays = move |url: Url| {
248            let token_source = token_source.clone();
249            let url = url.clone();
250            async move {
251                let mut client =
252                    CrpcEndhostApiClient::new(&url).map_err(ApiAttemptError::ClientSetup)?;
253                if let Some(token_source) = &token_source {
254                    client.use_token_source(token_source.clone());
255                }
256                let client = Arc::new(client);
257                let discovery = PeriodicUnderlayDiscovery::new(
258                    client.clone(),
259                    udp.udp_next_hop_resolver_fetch_interval,
260                    ExponentialBackoff::new(0.5, 10.0, 2.0, 0.5),
261                )
262                .await
263                .map_err(ApiAttemptError::UnderlayDiscovery)?;
264                Ok((client, discovery))
265            }
266        };
267
268        let (api_url, (endhost_api_client, underlay_discovery)) =
269            priority_connect::try_priority_groups(
270                api_groups,
271                discover_underlays,
272                endhost_api_discovery.per_group_delay,
273            )
274            .await
275            .map_err(|errors| {
276                BuildScionStackError::AllEndhostApisFailed(AllEndhostApisFailed(errors))
277            })?;
278        tracing::info!(%api_url, "Successfully selected endhost API");
279
280        // Use the endhost API URL to resolve the local IP addresses for the UDP underlay
281        // sockets.
282        // Here we assume that the interface used to reach the endhost API is
283        // the same as the interface used to reach the data planes.
284        let local_ip_resolver: Arc<dyn LocalIpResolver> = match udp.local_ips {
285            Some(ips) => Arc::new(ips),
286            None => {
287                Arc::new(
288                    TargetAddrLocalIpResolver::new(api_url.clone())
289                        .map_err(BuildUdpScionStackError::LocalIpResolutionError)?,
290                )
291            }
292        };
293
294        let underlay_stack = UnderlayStack::new(
295            preferred_underlay,
296            Arc::new(underlay_discovery),
297            local_ip_resolver,
298            snap.static_identity.unwrap_or_else(StaticSecret::random),
299            SnapSocketConfig {
300                snap_token_source: snap.snap_token_source.or(auth_token_source),
301            },
302        );
303
304        Ok(ScionStack::new(
305            Some(api_url),
306            endhost_api_client,
307            Arc::new(underlay_stack),
308        ))
309    }
310}
311
312impl Default for ScionStackBuilder {
313    fn default() -> Self {
314        Self::new()
315    }
316}
317
318/// Build SCION stack errors.
319#[derive(thiserror::Error, Debug)]
320pub enum BuildScionStackError {
321    /// Discovery returned no underlay or no underlay was provided.
322    #[error("no underlay available: {0}")]
323    UnderlayUnavailable(Cow<'static, str>),
324    /// All endhost APIs failed during client setup or underlay discovery.
325    #[error(transparent)]
326    AllEndhostApisFailed(#[from] AllEndhostApisFailed),
327    /// Failed to retrieve any endhost APIs from the discovery source.
328    #[error("endhost api source error: {0:#}")]
329    EndhostApiSourceError(#[from] EndhostApiSourceError),
330    /// Error building the SNAP SCION stack.
331    /// This error is only returned if a SNAP underlay is used.
332    #[error(transparent)]
333    Snap(#[from] BuildSnapScionStackError),
334    /// Error building the UDP SCION stack.
335    /// This error is only returned if a UDP underlay is used.
336    #[error(transparent)]
337    Udp(#[from] BuildUdpScionStackError),
338    /// Internal error, this should never happen.
339    #[error("internal error: {0:#}")]
340    Internal(anyhow::Error),
341}
342
343/// Build SNAP SCION stack errors.
344#[derive(thiserror::Error, Debug)]
345pub enum BuildSnapScionStackError {
346    /// Discovery returned no SNAP data plane.
347    #[error("no SNAP data plane available: {0}")]
348    DataPlaneUnavailable(Cow<'static, str>),
349    /// Error setting up the SNAP control plane client.
350    #[error("control plane client setup error: {0:#}")]
351    ControlPlaneClientSetupError(anyhow::Error),
352    /// Error making the data plane discovery request to the SNAP control plane.
353    #[error("data plane discovery request error: {0:#}")]
354    DataPlaneDiscoveryError(CrpcClientError),
355}
356
357/// Build UDP SCION stack errors.
358#[derive(thiserror::Error, Debug)]
359pub enum BuildUdpScionStackError {
360    /// Error resolving the local IP addresses.
361    #[error("local IP resolution error: {0:#}")]
362    LocalIpResolutionError(anyhow::Error),
363}
364
365/// Error returned when every attempted endhost API fails.
366///
367/// Formats as a single-line summary suitable for use in structured logs.
368#[derive(Debug)]
369pub struct AllEndhostApisFailed(pub Vec<(Url, ApiAttemptError)>);
370
371impl fmt::Display for AllEndhostApisFailed {
372    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
373        write!(f, "all {} endhost API(s) failed", self.0.len())?;
374        let mut sep = ": ";
375        for (url, err) in &self.0 {
376            write!(f, "{sep}{url} ({err})")?;
377            sep = "; ";
378        }
379        Ok(())
380    }
381}
382
383impl std::error::Error for AllEndhostApisFailed {}
384
385/// Error for a single endhost API connection attempt.
386#[derive(thiserror::Error, Debug)]
387pub enum ApiAttemptError {
388    /// The API client could not be instantiated (e.g. invalid URL scheme).
389    #[error("client setup: {0:#}")]
390    ClientSetup(anyhow::Error),
391    /// Underlay discovery against the API failed (e.g. server unreachable).
392    #[error("underlay discovery: {0:#}")]
393    UnderlayDiscovery(CrpcClientError),
394}
395
396/// Configuration for endhost API discovery during stack building.
397///
398/// Controls how many API groups and endpoints are probed in parallel, and
399/// how long to wait before falling through to the next priority group.
400pub struct EndhostApiDiscoveryConfig {
401    /// Maximum number of API groups to consider, in priority order.
402    max_groups: usize,
403    /// Maximum number of APIs to probe per group, selected at random.
404    apis_per_group: usize,
405    /// Delay before group `k` begins connecting (`k × per_group_delay`),
406    /// unless the previous group is exhausted sooner.
407    per_group_delay: Duration,
408}
409
410impl Default for EndhostApiDiscoveryConfig {
411    fn default() -> Self {
412        Self {
413            max_groups: DEFAULT_ENDHOST_API_DISCOVERY_MAX_GROUPS,
414            apis_per_group: DEFAULT_ENDHOST_API_DISCOVERY_APIS_PER_GROUP,
415            per_group_delay: DEFAULT_ENDHOST_API_DISCOVERY_PER_GROUP_DELAY,
416        }
417    }
418}
419
420/// Preferred underlay type (if available).
421pub enum PreferredUnderlay {
422    /// SNAP underlay.
423    Snap,
424    /// UDP underlay.
425    Udp,
426}
427
428/// SNAP underlay configuration.
429#[derive(Default)]
430pub struct SnapUnderlayConfig {
431    snap_token_source: Option<Arc<dyn TokenSource>>,
432    snap_dp_index: usize,
433    /// Private key used for snap-tun connections. If unset, a random static identity is generated.
434    static_identity: Option<StaticSecret>,
435}
436
437impl SnapUnderlayConfig {
438    /// Create a new [SnapUnderlayConfigBuilder] to configure the SNAP underlay.
439    pub fn builder() -> SnapUnderlayConfigBuilder {
440        SnapUnderlayConfigBuilder(Self::default())
441    }
442}
443
444/// SNAP underlay configuration builder.
445pub struct SnapUnderlayConfigBuilder(SnapUnderlayConfig);
446
447impl SnapUnderlayConfigBuilder {
448    /// Set a static token to use for authentication with the SNAP control plane.
449    pub fn with_auth_token(mut self, token: String) -> Self {
450        self.0.snap_token_source = Some(Arc::new(StaticTokenSource::from(token)));
451        self
452    }
453
454    /// Set a token source to use for authentication with the SNAP control plane.
455    pub fn with_auth_token_source(mut self, source: impl TokenSource) -> Self {
456        self.0.snap_token_source = Some(Arc::new(source));
457        self
458    }
459
460    /// Set the index of the SNAP data plane to use.
461    ///
462    /// # Arguments
463    ///
464    /// * `dp_index` - The index of the SNAP data plane to use.
465    pub fn with_snap_dp_index(mut self, dp_index: usize) -> Self {
466        self.0.snap_dp_index = dp_index;
467        self
468    }
469
470    /// Set the static identity to use for snap-tun connections.
471    /// If unset, a random static identity is generated.
472    pub fn with_static_identity(mut self, identity: StaticSecret) -> Self {
473        self.0.static_identity = Some(identity);
474        self
475    }
476
477    /// Build the SNAP stack configuration.
478    ///
479    /// # Returns
480    ///
481    /// A new SNAP stack configuration.
482    pub fn build(self) -> SnapUnderlayConfig {
483        self.0
484    }
485}
486
487/// UDP underlay configuration.
488pub struct UdpUnderlayConfig {
489    udp_next_hop_resolver_fetch_interval: Duration,
490    local_ips: Option<Vec<net::IpAddr>>,
491}
492
493impl Default for UdpUnderlayConfig {
494    fn default() -> Self {
495        Self {
496            udp_next_hop_resolver_fetch_interval: DEFAULT_UDP_NEXT_HOP_RESOLVER_FETCH_INTERVAL,
497            local_ips: None,
498        }
499    }
500}
501
502impl UdpUnderlayConfig {
503    /// Create a new [UdpUnderlayConfigBuilder] to configure the UDP underlay.
504    pub fn builder() -> UdpUnderlayConfigBuilder {
505        UdpUnderlayConfigBuilder(Self::default())
506    }
507}
508
509/// UDP underlay configuration builder.
510pub struct UdpUnderlayConfigBuilder(UdpUnderlayConfig);
511
512impl UdpUnderlayConfigBuilder {
513    /// Set the local IP addresses to use for the UDP underlay.
514    /// If not set, the UDP underlay will use the local IP that can reach the endhost API.
515    pub fn with_local_ips(mut self, local_ips: Vec<net::IpAddr>) -> Self {
516        self.0.local_ips = Some(local_ips);
517        self
518    }
519
520    /// Set the interval at which the UDP next hop resolver fetches the next hops
521    /// from the endhost API.
522    pub fn with_udp_next_hop_resolver_fetch_interval(mut self, fetch_interval: Duration) -> Self {
523        self.0.udp_next_hop_resolver_fetch_interval = fetch_interval;
524        self
525    }
526
527    /// Build the UDP underlay configuration.
528    pub fn build(self) -> UdpUnderlayConfig {
529        self.0
530    }
531}