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