Skip to main content

nym_http_api_client/
fronted.rs

1// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
2// SPDX-License-Identifier: Apache-2.0
3
4//! Utilities for and implementation of request tunneling
5
6use std::sync::{
7    Arc, LazyLock, RwLock,
8    atomic::{AtomicBool, Ordering},
9};
10use tracing::warn;
11
12use crate::{Client, ClientBuilder};
13
14static SHARED_FRONTING_POLICY: LazyLock<Arc<RwLock<FrontPolicy>>> =
15    LazyLock::new(|| Arc::new(RwLock::new(FrontPolicy::Off)));
16
17// #[cfg(feature = "tunneling")]
18#[derive(Debug)]
19pub(crate) struct Front {
20    pub(crate) policy: Arc<RwLock<FrontPolicy>>,
21    enabled: AtomicBool,
22}
23
24impl Clone for Front {
25    fn clone(&self) -> Self {
26        Self {
27            policy: self.policy.clone(),
28            enabled: AtomicBool::new(false),
29        }
30    }
31}
32
33impl Front {
34    pub(crate) fn new(policy: FrontPolicy) -> Self {
35        Self {
36            enabled: AtomicBool::new(false),
37            policy: Arc::new(RwLock::new(policy)),
38        }
39    }
40
41    pub(crate) fn off() -> Self {
42        Self::new(FrontPolicy::Off)
43    }
44
45    pub(crate) fn shared() -> Self {
46        let policy = SHARED_FRONTING_POLICY.clone();
47        Self {
48            enabled: AtomicBool::new(false),
49            policy,
50        }
51    }
52
53    pub(crate) fn set_policy(&self, policy: FrontPolicy) {
54        *self.policy.write().unwrap() = policy;
55        self.enabled.store(false, Ordering::Relaxed);
56    }
57
58    pub(crate) fn is_enabled(&self) -> bool {
59        match *self.policy.read().unwrap() {
60            FrontPolicy::Off => false,
61            FrontPolicy::OnRetry => self.enabled.load(Ordering::Relaxed),
62            FrontPolicy::Always => true,
63        }
64    }
65
66    // Used to indicate that the client hit an error that should trigger the retry policy
67    // to enable fronting.
68    pub(crate) fn retry_enable(&self) {
69        if self.is_enabled() {
70            return;
71        }
72        if matches!(*self.policy.read().unwrap(), FrontPolicy::OnRetry) {
73            self.enabled.store(true, Ordering::Relaxed);
74        }
75    }
76}
77
78#[derive(Debug, Default, PartialEq, Clone)]
79/// Policy for when to use domain fronting for HTTP requests.
80pub enum FrontPolicy {
81    /// Always use domain fronting for all requests.
82    Always,
83    /// Only use domain fronting when retrying failed requests.
84    OnRetry,
85    #[default]
86    /// Never use domain fronting.
87    Off,
88}
89
90impl ClientBuilder {
91    /// Enable and configure request tunneling for API requests. If no front policy is
92    /// provided the shared fronting policy will be used.
93    pub fn with_fronting(mut self, policy: Option<FrontPolicy>) -> Self {
94        let front = if let Some(p) = policy {
95            Front::new(p)
96        } else {
97            Front::shared()
98        };
99
100        // Check if any of the supplied urls even support fronting
101        if !self.urls.iter().any(|url| url.has_front()) {
102            warn!(
103                "fronting is enabled, but none of the supplied urls have configured fronting domains: {:?}",
104                self.urls
105            );
106        }
107
108        self.front = front;
109
110        self
111    }
112}
113
114impl Client {
115    /// Set the policy for enabling fronting. If fronting was previously unset this will set it, and
116    /// make it possible to enable (i.e [`FrontPolicy::Off`] will not enable it).
117    ///
118    /// Calling this function sets a custom policy for this client, disconnecting it from the shared
119    /// fronting policy -- i.e. changes applied through [`Client::set_shared_front_policy`] will not
120    /// be impact this client.
121    pub fn set_front_policy(&mut self, policy: FrontPolicy) {
122        self.front.set_policy(policy)
123    }
124
125    /// Set the fronting policy for this client to follow the shared policy.
126    pub fn use_shared_front_policy(&mut self) {
127        self.front = Front::shared();
128    }
129
130    /// Set the fronting policy for all clients using the shared policy.
131    //
132    // NOTE: this does not reset the per-instance enabled flag like it will when using
133    // [`Front::set_front_policy`]. So if a client is using shared policy with the `OnRetry` policy
134    // and this function is used to swap that policy away from and then back to `OnRetry` the
135    // fronting will still be enabled. Noting this here just in case this triggers any corner cases
136    // down the road.
137    pub fn set_shared_front_policy(policy: FrontPolicy) {
138        *SHARED_FRONTING_POLICY.write().unwrap() = policy;
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::{ApiClientCore, NO_PARAMS, Url};
146    use serial_test::serial;
147
148    impl Front {
149        pub(crate) fn policy(&self) -> FrontPolicy {
150            self.policy.read().unwrap().clone()
151        }
152    }
153
154    /// Policy can be set for an independent client and the update is applied properly
155    #[test]
156    fn set_policy_independent_client() {
157        let url1 = Url::new(
158            "https://validator.global.ssl.fastly.net",
159            Some(vec!["https://yelp.global.ssl.fastly.net"]),
160        )
161        .unwrap();
162
163        let mut client1 = ClientBuilder::new(url1.clone())
164            .unwrap()
165            .with_fronting(Some(FrontPolicy::Off))
166            .build()
167            .unwrap();
168        assert!(client1.front.policy() == FrontPolicy::Off);
169
170        let client2 = ClientBuilder::new(url1.clone())
171            .unwrap()
172            .with_fronting(Some(FrontPolicy::OnRetry))
173            .build()
174            .unwrap();
175
176        // Ensure that setting the policy for a client it gets properly applied.
177        client1.set_front_policy(FrontPolicy::Always);
178        assert!(client1.front.policy() == FrontPolicy::Always);
179
180        // ensure that setting the policy in a client NOT using the shared policy does NOT update
181        // the policy used by another client.
182        assert!(client2.front.policy() == FrontPolicy::OnRetry);
183
184        // Ensure that the policy takes effect and is applied when setting host headers on outgoing
185        // requests
186        let req = client1
187            .create_request(reqwest::Method::GET, &["/"], NO_PARAMS, None::<&()>)
188            .unwrap()
189            .build()
190            .unwrap();
191
192        let expected_host = url1.host_str().unwrap();
193        assert!(
194            req.headers()
195                .get(reqwest::header::HOST)
196                .is_some_and(|h| h.to_str().unwrap() == expected_host),
197            "{:?} != {:?}",
198            expected_host,
199            req,
200        );
201
202        let expected_front = url1.front_str().unwrap();
203        assert!(
204            req.url()
205                .host()
206                .is_some_and(|url| url.to_string() == expected_front),
207            "{:?} != {:?}",
208            expected_front,
209            req,
210        );
211    }
212
213    /// Policy can be set for the shared client and the update is applied properly
214    // NOTE THIS TEST IS DISABLED BECAUSE IT INTERACTS WITH THE SHARED POLICY AND AS SUCH CAN HAVE
215    // AN IMPACT ON OTHER TESTS
216    #[test]
217    #[serial]
218    fn set_policy_shared_client() {
219        // restores the shared policy to whatever it was before this test on scope exit (including
220        // on panic/assertion failure), so a leaked mutation can't leak into whichever test the
221        // shared-state lock is handed to next.
222        struct RestoreFrontPolicy(FrontPolicy);
223        impl Drop for RestoreFrontPolicy {
224            fn drop(&mut self) {
225                *SHARED_FRONTING_POLICY.write().unwrap() = self.0.clone();
226            }
227        }
228        let _restore_policy = RestoreFrontPolicy(SHARED_FRONTING_POLICY.read().unwrap().clone());
229
230        let url1 = Url::new(
231            "https://validator.global.ssl.fastly.net",
232            Some(vec!["https://yelp.global.ssl.fastly.net"]),
233        )
234        .unwrap();
235
236        Client::set_shared_front_policy(FrontPolicy::Off);
237        assert!(*SHARED_FRONTING_POLICY.read().unwrap() == FrontPolicy::Off);
238
239        let client1 = ClientBuilder::new(url1.clone())
240            .unwrap()
241            .with_fronting(None)
242            .build()
243            .unwrap();
244        assert!(client1.front.policy() == FrontPolicy::Off);
245
246        let mut client2 = ClientBuilder::new(url1.clone())
247            .unwrap()
248            .with_fronting(Some(FrontPolicy::Off))
249            .build()
250            .unwrap();
251
252        // Ensure that setting the shared policy gets properly applied
253        Client::set_shared_front_policy(FrontPolicy::Always);
254        assert!(client1.front.policy() == FrontPolicy::Always);
255
256        // Setting the shared policy should NOT update clients NOT using the shared policy.
257        assert!(client2.front.policy() == FrontPolicy::Off);
258
259        // Ensure that the policy takes effect and is applied when setting host headers on outgoing
260        // requests
261        let req = client1
262            .create_request(reqwest::Method::GET, &["/"], NO_PARAMS, None::<&()>)
263            .unwrap()
264            .build()
265            .unwrap();
266
267        let expected_host = url1.host_str().unwrap();
268        assert!(
269            req.headers()
270                .get(reqwest::header::HOST)
271                .is_some_and(|h| h.to_str().unwrap() == expected_host),
272            "{:?} != {:?}",
273            expected_host,
274            req,
275        );
276
277        let expected_front = url1.front_str().unwrap();
278        assert!(
279            req.url()
280                .host()
281                .is_some_and(|url| url.to_string() == expected_front),
282            "{:?} != {:?}",
283            expected_front,
284            req,
285        );
286
287        // ensure that setting to the shared policy works
288        client2.use_shared_front_policy();
289        assert!(client2.front.policy() == FrontPolicy::Always);
290
291        // ensure that if the policy is OnRetry then the `enabled` fields are still independent,
292        // despite the policy being shared.
293        Client::set_shared_front_policy(FrontPolicy::OnRetry);
294        assert!(client1.front.policy() == FrontPolicy::OnRetry);
295        assert!(client2.front.policy() == FrontPolicy::OnRetry);
296
297        assert!(!client1.front.is_enabled());
298        assert!(!client2.front.is_enabled());
299
300        client1.front.retry_enable();
301        assert!(client1.front.is_enabled());
302        assert!(!client2.front.is_enabled());
303    }
304
305    #[tokio::test]
306    #[serial]
307    async fn nym_api_works() {
308        // sends a real request, which reads the process-wide SHARED_NETWORK_RECONFIGURATION
309        // marker - must not run concurrently with tests that mutate it.
310
311        let url1 = Url::new(
312            "https://validator.global.ssl.fastly.net",
313            Some(vec!["https://yelp.global.ssl.fastly.net"]),
314        )
315        .unwrap(); // fastly
316
317        // let url2 = Url::new(
318        //     "https://validator.nymtech.net",
319        //     Some(vec!["https://cdn77.com"]),
320        // ).unwrap(); // cdn77
321
322        let client = ClientBuilder::new(url1)
323            .expect("bad url")
324            .with_fronting(Some(FrontPolicy::Always))
325            .build()
326            .expect("failed to build client");
327
328        let response = client
329            .send_request::<_, (), &str, &str>(
330                reqwest::Method::GET,
331                &["api", "v1", "network", "details"],
332                NO_PARAMS,
333                None,
334            )
335            .await
336            .expect("failed get request");
337
338        // println!("{response:?}");
339        assert_eq!(response.status(), 200);
340    }
341
342    #[tokio::test]
343    #[serial]
344    async fn fallback_on_failure() {
345        // sends real requests and relies on host rotation not being suppressed, which depends on
346        // the process-wide SHARED_NETWORK_RECONFIGURATION marker - must not run concurrently with
347        // tests that mutate it.
348
349        let url1 = Url::new(
350            "https://fake-domain.nymtech.net",
351            Some(vec![
352                "https://fake-front-1.nymtech.net",
353                "https://fake-front-2.nymtech.net",
354            ]),
355        )
356        .unwrap();
357        let url2 = Url::new(
358            "https://validator.global.ssl.fastly.net",
359            Some(vec!["https://yelp.global.ssl.fastly.net"]),
360        )
361        .unwrap(); // fastly
362
363        let client = ClientBuilder::new_with_urls(vec![url1, url2])
364            .expect("bad url")
365            .with_fronting(Some(FrontPolicy::Always))
366            .build()
367            .expect("failed to build client");
368
369        // Check that the initial configuration has the broken domain and front.
370        assert_eq!(
371            client.current_url().as_str(),
372            "https://fake-domain.nymtech.net/",
373        );
374        assert_eq!(
375            client.current_url().front_str(),
376            Some("fake-front-1.nymtech.net"),
377        );
378
379        let result = client
380            .send_request::<_, (), &str, &str>(
381                reqwest::Method::GET,
382                &["api", "v1", "network", "details"],
383                NO_PARAMS,
384                None,
385            )
386            .await;
387        assert!(result.is_err());
388
389        // Check that the host configuration updated the front on error.
390        assert_eq!(
391            client.current_url().as_str(),
392            "https://fake-domain.nymtech.net/",
393        );
394        assert_eq!(
395            client.current_url().front_str(),
396            Some("fake-front-2.nymtech.net"),
397        );
398
399        let result = client
400            .send_request::<_, (), &str, &str>(
401                reqwest::Method::GET,
402                &["api", "v1", "network", "details"],
403                NO_PARAMS,
404                None,
405            )
406            .await;
407        assert!(result.is_err());
408
409        // Check that the host configuration updated the domain and front on error.
410        assert_eq!(
411            client.current_url().as_str(),
412            "https://validator.global.ssl.fastly.net/",
413        );
414        assert_eq!(
415            client.current_url().front_str(),
416            Some("yelp.global.ssl.fastly.net"),
417        );
418
419        let response = client
420            .send_request::<_, (), &str, &str>(
421                reqwest::Method::GET,
422                &["api", "v1", "network", "details"],
423                NO_PARAMS,
424                None,
425            )
426            .await
427            .expect("failed get request");
428
429        assert_eq!(response.status(), 200);
430    }
431}