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}