Skip to main content

scion_http3/
config.rs

1// Copyright 2026 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
15//! Client configuration.
16
17use std::{fmt, sync::Arc, time::Duration};
18
19use async_trait::async_trait;
20use scion_quic::quic::config::QuicConfig;
21use scion_stack::{
22    ScionStackBuilder,
23    ea_source::{EndhostApiSource, EndhostApiSourceError, models::EndhostApiGroup},
24    reqwest_connect_rpc::token_source::{
25        TokenSource, TokenSourceError, TokenSourceWatch, static_token::StaticTokenSource,
26    },
27    resolver::ScionDnsResolver,
28    stack::builder::PreferredUnderlay,
29};
30use sciparse::address::ip_addr::ScionIpAddr;
31use url::Url;
32
33/// Default timeout for establishing a connection to an origin.
34pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
35/// Default timeout for a request (response head plus body collection).
36pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
37/// Default idle time after which an origin's connection is swept.
38///
39/// Deliberately below the default QUIC idle timeout (30 seconds,
40/// [`scion_quic::quic::config::QuicConfig`]): QUIC kills an idle connection
41/// after that and keep-alives are off, so sweeping later would pool
42/// connections that are already dead.
43pub const DEFAULT_IDLE_CONNECTION_TIMEOUT: Duration = Duration::from_secs(25);
44/// Default bound on the number of distinct origins with pooled connections.
45pub const DEFAULT_MAX_ORIGINS: usize = 8;
46/// Default stagger between connection attempts to an origin's candidate
47/// addresses (RFC 8305, Happy Eyeballs).
48pub const DEFAULT_CONNECTION_ATTEMPT_DELAY: Duration = Duration::from_millis(250);
49
50/// The type of the [`ScionStackBuilder`] customization hook, see
51/// [`Config::with_stack_customizer`].
52pub(crate) type StackCustomizer =
53    dyn Fn(ScionStackBuilder) -> ScionStackBuilder + Send + Sync + 'static;
54
55/// Adapts a shared [`TokenSource`] to the by-value `impl TokenSource` the stack
56/// builder takes.
57///
58/// A [`Config`] is cloneable and outlives every stack built from it, so it holds
59/// the source behind an `Arc` and hands the builder one of these on each
60/// rebuild. Both methods delegate, so a refreshing source keeps refreshing.
61pub(crate) struct SharedTokenSource(pub(crate) Arc<dyn TokenSource>);
62
63#[async_trait]
64impl TokenSource for SharedTokenSource {
65    fn watch(&self) -> TokenSourceWatch {
66        self.0.watch()
67    }
68
69    async fn get_token(&self) -> Result<String, TokenSourceError> {
70        self.0.get_token().await
71    }
72}
73
74/// Adapts a shared [`EndhostApiSource`] to the by-value `impl EndhostApiSource`
75/// the stack builder takes, for the same reason as [`SharedTokenSource`].
76pub(crate) struct SharedEndhostApiSource(pub(crate) Arc<dyn EndhostApiSource>);
77
78#[async_trait]
79impl EndhostApiSource for SharedEndhostApiSource {
80    async fn endhost_apis(&self) -> Result<Vec<EndhostApiGroup>, EndhostApiSourceError> {
81        self.0.endhost_apis().await
82    }
83}
84
85/// Where a [`Config`] finds its endhost APIs.
86#[derive(Clone)]
87pub(crate) enum EndhostApi {
88    /// One fixed endhost API.
89    Fixed(Url),
90    /// The endhost APIs from a source.
91    Source(Arc<dyn EndhostApiSource>),
92}
93
94impl fmt::Debug for EndhostApi {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        match self {
97            EndhostApi::Fixed(url) => f.debug_tuple("Fixed").field(url).finish(),
98            EndhostApi::Source(_) => f.debug_tuple("Source").field(&"<source>").finish(),
99        }
100    }
101}
102
103/// Configuration for a [`Client`](crate::Client).
104///
105/// Construct with [`Config::new`] for one fixed endhost API, or with
106/// [`Config::from_endhost_api_source`] to discover the endhost APIs, and
107/// customize with the consuming `with_*` methods. The endhost API is the only
108/// required setting: it is how the client discovers the data planes available
109/// to it, and the same single setting reaches a real SCION network or a local
110/// `PocketSCION` topology.
111#[derive(Clone)]
112pub struct Config {
113    pub(crate) endhost_api: EndhostApi,
114    pub(crate) auth_token_source: Option<Arc<dyn TokenSource>>,
115    pub(crate) preferred_underlay: Option<PreferredUnderlay>,
116    pub(crate) stack_customizer: Option<Arc<StackCustomizer>>,
117    pub(crate) connect_timeout: Duration,
118    pub(crate) request_timeout: Duration,
119    pub(crate) idle_connection_timeout: Duration,
120    pub(crate) max_origins: usize,
121    pub(crate) connection_attempt_delay: Duration,
122    pub(crate) quic: QuicConfig,
123    pub(crate) resolver: Option<Arc<dyn ScionDnsResolver>>,
124    pub(crate) dns_overrides: Vec<(String, Vec<ScionIpAddr>)>,
125}
126
127impl Config {
128    /// Creates a configuration that discovers connectivity through the given
129    /// endhost API.
130    #[must_use]
131    pub fn new(endhost_api: Url) -> Self {
132        Config::with_defaults(EndhostApi::Fixed(endhost_api))
133    }
134
135    /// Creates a configuration that gets the endhost APIs from `source`, for
136    /// example from the global discovery service
137    /// ([`StaticEndhostApiDiscovery::global`](scion_stack::ea_source::StaticEndhostApiDiscovery::global)).
138    ///
139    /// All clones of the configuration share `source`. Each stack that the
140    /// client builds gets the endhost APIs from `source` again: on first use
141    /// and after each [`reset`](crate::Client::reset).
142    ///
143    /// If `source` fails, the request fails with
144    /// [`Error::StackBuild`](crate::Error::StackBuild). The error is retryable
145    /// if the source error is transient.
146    #[must_use]
147    pub fn from_endhost_api_source(source: impl EndhostApiSource) -> Self {
148        Config::with_defaults(EndhostApi::Source(Arc::new(source)))
149    }
150
151    fn with_defaults(endhost_api: EndhostApi) -> Self {
152        Config {
153            endhost_api,
154            auth_token_source: None,
155            preferred_underlay: None,
156            stack_customizer: None,
157            connect_timeout: DEFAULT_CONNECT_TIMEOUT,
158            request_timeout: DEFAULT_REQUEST_TIMEOUT,
159            idle_connection_timeout: DEFAULT_IDLE_CONNECTION_TIMEOUT,
160            max_origins: DEFAULT_MAX_ORIGINS,
161            connection_attempt_delay: DEFAULT_CONNECTION_ATTEMPT_DELAY,
162            quic: QuicConfig::default(),
163            resolver: None,
164            dns_overrides: Vec::new(),
165        }
166    }
167
168    /// Sets a fixed token to authenticate with the endhost API and the SNAP
169    /// control plane.
170    ///
171    /// Use [`with_auth_token_source`](Self::with_auth_token_source) instead when
172    /// the token expires: connectivity is rebuilt on every
173    /// [`reset`](crate::Client::reset), and a token captured here is reused
174    /// verbatim each time.
175    #[must_use]
176    pub fn with_auth_token(self, token: impl Into<String>) -> Self {
177        self.with_auth_token_source(StaticTokenSource::from(token.into()))
178    }
179
180    /// Sets the token *source* used to authenticate with the endhost API and
181    /// the SNAP control plane.
182    ///
183    /// The source is shared, not rebuilt: the same one is handed to every stack
184    /// this configuration builds, so a refreshing source keeps its schedule and
185    /// its cached token across a [`reset`](crate::Client::reset). Implement
186    /// [`TokenSource`](scion_stack::reqwest_connect_rpc::token_source::TokenSource)
187    /// to supply tokens that expire.
188    ///
189    /// For a source scoped to the endhost API alone, reach the builder's
190    /// `with_endhost_api_auth_token_source` through
191    /// [`with_stack_customizer`](Self::with_stack_customizer).
192    ///
193    /// A failing source fails the request with
194    /// [`Error::StackBuild`](crate::Error::StackBuild), which is retryable if
195    /// the source error is transient.
196    #[must_use]
197    pub fn with_auth_token_source(mut self, source: impl TokenSource) -> Self {
198        self.auth_token_source = Some(Arc::new(source));
199        self
200    }
201
202    /// Sets the preferred underlay, applied to whatever endhost API discovery
203    /// returns (default: UDP).
204    #[must_use]
205    pub fn with_preferred_underlay(mut self, underlay: PreferredUnderlay) -> Self {
206        self.preferred_underlay = Some(underlay);
207        self
208    }
209
210    /// Customizes the [`ScionStackBuilder`] used to build connectivity, for
211    /// settings this configuration does not expose directly (underlay-specific
212    /// configuration, discovery tuning, token sources).
213    ///
214    /// The hook runs every time connectivity is (re)built: on first use and
215    /// after every [`reset`](crate::Client::reset). It runs after the settings
216    /// above are applied, so it can override them.
217    // XXX(shitz): This hook exists because `SnapUnderlayConfig`/`UdpUnderlayConfig`
218    // are single-use (not `Clone`) while every rebuild needs a fresh
219    // `ScionStackBuilder` from the same settings. Once they are made `Clone`
220    // in scion-stack, replace this with direct `with_snap_underlay_config` and
221    // `with_udp_underlay_config` setters.
222    #[must_use]
223    pub fn with_stack_customizer(
224        mut self,
225        customizer: impl Fn(ScionStackBuilder) -> ScionStackBuilder + Send + Sync + 'static,
226    ) -> Self {
227        self.stack_customizer = Some(Arc::new(customizer));
228        self
229    }
230
231    /// Sets the timeout for establishing a connection to an origin, covering
232    /// name resolution and all staggered connection attempts.
233    #[must_use]
234    pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
235        self.connect_timeout = timeout;
236        self
237    }
238
239    /// Sets the default timeout for a whole request: connectivity build,
240    /// establishment, response head, and body collection. Overridable per
241    /// request with
242    /// [`RequestBuilder::request_timeout`](crate::RequestBuilder::request_timeout).
243    ///
244    /// It is one deadline across all of those phases, and it keeps running while
245    /// the caller holds the [`Response`](crate::Response) — see there for what
246    /// that means for body collection.
247    #[must_use]
248    pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
249        self.request_timeout = timeout;
250        self
251    }
252
253    /// Sets how long an origin's pooled connection may sit unused before an
254    /// opportunistic sweep reclaims it (and its socket).
255    #[must_use]
256    pub fn with_idle_connection_timeout(mut self, timeout: Duration) -> Self {
257        self.idle_connection_timeout = timeout;
258        self
259    }
260
261    /// Bounds the number of distinct origins with pooled connections. Each
262    /// pooled connection holds a socket, and therefore a file descriptor;
263    /// when the bound is hit, the least recently used origin is evicted (and
264    /// the eviction logged).
265    #[must_use]
266    pub fn with_max_origins(mut self, max_origins: usize) -> Self {
267        self.max_origins = max_origins;
268        self
269    }
270
271    /// Sets the stagger between connection attempts to an origin's candidate
272    /// addresses: the next candidate is only tried this long after the
273    /// previous one, unless it already failed (RFC 8305, Happy Eyeballs).
274    #[must_use]
275    pub fn with_connection_attempt_delay(mut self, delay: Duration) -> Self {
276        self.connection_attempt_delay = delay;
277        self
278    }
279
280    /// Sets the QUIC configuration used for every connection: trust anchors
281    /// (`ca_certs_file` / `ca_certs_dir` / `ca_certs_pem`) or a certificate
282    /// verifier (`with_cert_verifier` / `with_platform_verifier`), peer
283    /// verification, handshake and idle timeouts, and transport tuning. The application
284    /// protocol list must contain `h3` (the default).
285    #[must_use]
286    pub fn with_quic_config(mut self, quic: QuicConfig) -> Self {
287        self.quic = quic;
288        self
289    }
290
291    /// Replaces the default DNS resolver
292    /// ([`ScionTxtDnsResolver`](scion_stack::resolver::txt::ScionTxtDnsResolver))
293    /// with a caller-supplied one.
294    ///
295    /// The default resolver is constructed fresh on every connectivity
296    /// rebuild, because system DNS configuration is a property of the current
297    /// network. An injected resolver is reused across rebuilds as-is;
298    /// reacting to network changes is then its responsibility.
299    #[must_use]
300    pub fn with_resolver(mut self, resolver: Arc<dyn ScionDnsResolver>) -> Self {
301        self.resolver = Some(resolver);
302        self
303    }
304
305    /// Resolves `host` to `addrs` instead of looking it up, for a host that
306    /// has no TSAR records. Applies to URLs and to `CONNECT` authorities
307    /// alike.
308    ///
309    /// Only the default resolver applies overrides. A resolver injected with
310    /// [`with_resolver`](Self::with_resolver) does its own resolution.
311    #[must_use]
312    pub fn with_dns_override(mut self, host: impl Into<String>, addrs: Vec<ScionIpAddr>) -> Self {
313        self.dns_overrides
314            .push((host.into().to_ascii_lowercase(), addrs));
315        self
316    }
317}
318
319impl fmt::Debug for Config {
320    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
321        f.debug_struct("Config")
322            .field("endhost_api", &self.endhost_api)
323            .field(
324                "auth_token_source",
325                &self.auth_token_source.as_ref().map(|_| "<redacted>"),
326            )
327            .field("preferred_underlay", &self.preferred_underlay)
328            .field(
329                "stack_customizer",
330                &self.stack_customizer.as_ref().map(|_| "<fn>"),
331            )
332            .field("connect_timeout", &self.connect_timeout)
333            .field("request_timeout", &self.request_timeout)
334            .field("idle_connection_timeout", &self.idle_connection_timeout)
335            .field("max_origins", &self.max_origins)
336            .field("connection_attempt_delay", &self.connection_attempt_delay)
337            .field("quic", &self.quic)
338            .field("resolver", &self.resolver.as_ref().map(|_| "<custom>"))
339            .field("dns_overrides", &self.dns_overrides)
340            .finish()
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    fn config() -> Config {
349        Config::new("https://endhost-api.example.org".parse().unwrap())
350    }
351
352    #[test]
353    fn new_applies_defaults() {
354        let config = config();
355        assert_eq!(config.connect_timeout, DEFAULT_CONNECT_TIMEOUT);
356        assert_eq!(config.request_timeout, DEFAULT_REQUEST_TIMEOUT);
357        assert_eq!(
358            config.idle_connection_timeout,
359            DEFAULT_IDLE_CONNECTION_TIMEOUT
360        );
361        assert_eq!(config.max_origins, DEFAULT_MAX_ORIGINS);
362        assert_eq!(
363            config.connection_attempt_delay,
364            DEFAULT_CONNECTION_ATTEMPT_DELAY
365        );
366    }
367
368    #[test]
369    fn default_idle_sweep_precedes_quic_idle_timeout() {
370        let config = config();
371        assert!(config.idle_connection_timeout < config.quic.idle_timeout);
372    }
373
374    #[test]
375    fn debug_shows_the_fixed_endhost_api_or_a_source() {
376        let debug = format!("{:?}", config());
377        assert!(debug.contains("endhost-api.example.org"), "{debug}");
378
379        let config = Config::from_endhost_api_source(
380            scion_stack::ea_source::StaticEndhostApiDiscovery::global(),
381        );
382        let debug = format!("{config:?}");
383        assert!(debug.contains("<source>"), "{debug}");
384    }
385
386    #[test]
387    fn debug_redacts_the_auth_token() {
388        let config = config().with_auth_token("super-secret-token");
389        let debug = format!("{config:?}");
390        assert!(!debug.contains("super-secret-token"));
391        assert!(debug.contains("<redacted>"));
392    }
393}