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
147    impl Front {
148        pub(crate) fn policy(&self) -> FrontPolicy {
149            self.policy.read().unwrap().clone()
150        }
151    }
152
153    /// Policy can be set for an independent client and the update is applied properly
154    #[test]
155    fn set_policy_independent_client() {
156        let url1 = Url::new(
157            "https://validator.global.ssl.fastly.net",
158            Some(vec!["https://yelp.global.ssl.fastly.net"]),
159        )
160        .unwrap();
161
162        let mut client1 = ClientBuilder::new(url1.clone())
163            .unwrap()
164            .with_fronting(Some(FrontPolicy::Off))
165            .build()
166            .unwrap();
167        assert!(client1.front.policy() == FrontPolicy::Off);
168
169        let client2 = ClientBuilder::new(url1.clone())
170            .unwrap()
171            .with_fronting(Some(FrontPolicy::OnRetry))
172            .build()
173            .unwrap();
174
175        // Ensure that setting the policy for a client it gets properly applied.
176        client1.set_front_policy(FrontPolicy::Always);
177        assert!(client1.front.policy() == FrontPolicy::Always);
178
179        // ensure that setting the policy in a client NOT using the shared policy does NOT update
180        // the policy used by another client.
181        assert!(client2.front.policy() == FrontPolicy::OnRetry);
182
183        // Ensure that the policy takes effect and is applied when setting host headers on outgoing
184        // requests
185        let req = client1
186            .create_request(reqwest::Method::GET, &["/"], NO_PARAMS, None::<&()>)
187            .unwrap()
188            .build()
189            .unwrap();
190
191        let expected_host = url1.host_str().unwrap();
192        assert!(
193            req.headers()
194                .get(reqwest::header::HOST)
195                .is_some_and(|h| h.to_str().unwrap() == expected_host),
196            "{:?} != {:?}",
197            expected_host,
198            req,
199        );
200
201        let expected_front = url1.front_str().unwrap();
202        assert!(
203            req.url()
204                .host()
205                .is_some_and(|url| url.to_string() == expected_front),
206            "{:?} != {:?}",
207            expected_front,
208            req,
209        );
210    }
211
212    /// Policy can be set for the shared client and the update is applied properly
213    // NOTE THIS TEST IS DISABLED BECAUSE IT INTERACTS WITH THE SHARED POLICY AND AS SUCH CAN HAVE
214    // AN IMPACT ON OTHER TESTS
215    #[test]
216    #[cfg(any())] // #[ignore] we run --ignore in CI/CD assuming it just means slow -_-
217    fn set_policy_shared_client() {
218        let url1 = Url::new(
219            "https://validator.global.ssl.fastly.net",
220            Some(vec!["https://yelp.global.ssl.fastly.net"]),
221        )
222        .unwrap();
223
224        Client::set_shared_front_policy(FrontPolicy::Off);
225        assert!(*SHARED_FRONTING_POLICY.read().unwrap() == FrontPolicy::Off);
226
227        let client1 = ClientBuilder::new(url1.clone())
228            .unwrap()
229            .with_fronting(None)
230            .build()
231            .unwrap();
232        assert!(client1.front.policy() == FrontPolicy::Off);
233
234        let mut client2 = ClientBuilder::new(url1.clone())
235            .unwrap()
236            .with_fronting(Some(FrontPolicy::Off))
237            .build()
238            .unwrap();
239
240        // Ensure that setting the shared policy gets properly applied
241        Client::set_shared_front_policy(FrontPolicy::Always);
242        assert!(client1.front.policy() == FrontPolicy::Always);
243
244        // Setting the shared policy should NOT update clients NOT using the shared policy.
245        assert!(client2.front.policy() == FrontPolicy::Off);
246
247        // Ensure that the policy takes effect and is applied when setting host headers on outgoing
248        // requests
249        let req = client1
250            .create_request(reqwest::Method::GET, &["/"], NO_PARAMS, None::<&()>)
251            .unwrap()
252            .build()
253            .unwrap();
254
255        let expected_host = url1.host_str().unwrap();
256        assert!(
257            req.headers()
258                .get(reqwest::header::HOST)
259                .is_some_and(|h| h.to_str().unwrap() == expected_host),
260            "{:?} != {:?}",
261            expected_host,
262            req,
263        );
264
265        let expected_front = url1.front_str().unwrap();
266        assert!(
267            req.url()
268                .host()
269                .is_some_and(|url| url.to_string() == expected_front),
270            "{:?} != {:?}",
271            expected_front,
272            req,
273        );
274
275        // ensure that setting to the shared policy works
276        client2.use_shared_front_policy();
277        assert!(client2.front.policy() == FrontPolicy::Always);
278
279        // ensure that if the policy is OnRetry then the `enabled` fields are still independent,
280        // despite the policy being shared.
281        Client::set_shared_front_policy(FrontPolicy::OnRetry);
282        assert!(client1.front.policy() == FrontPolicy::OnRetry);
283        assert!(client2.front.policy() == FrontPolicy::OnRetry);
284
285        assert!(!client1.front.is_enabled());
286        assert!(!client2.front.is_enabled());
287
288        client1.front.retry_enable();
289        assert!(client1.front.is_enabled());
290        assert!(!client2.front.is_enabled());
291    }
292
293    #[tokio::test]
294    async fn nym_api_works() {
295        let url1 = Url::new(
296            "https://validator.global.ssl.fastly.net",
297            Some(vec!["https://yelp.global.ssl.fastly.net"]),
298        )
299        .unwrap(); // fastly
300
301        // let url2 = Url::new(
302        //     "https://validator.nymtech.net",
303        //     Some(vec!["https://cdn77.com"]),
304        // ).unwrap(); // cdn77
305
306        let client = ClientBuilder::new(url1)
307            .expect("bad url")
308            .with_fronting(Some(FrontPolicy::Always))
309            .build()
310            .expect("failed to build client");
311
312        let response = client
313            .send_request::<_, (), &str, &str>(
314                reqwest::Method::GET,
315                &["api", "v1", "network", "details"],
316                NO_PARAMS,
317                None,
318            )
319            .await
320            .expect("failed get request");
321
322        // println!("{response:?}");
323        assert_eq!(response.status(), 200);
324    }
325
326    #[tokio::test]
327    async fn fallback_on_failure() {
328        let url1 = Url::new(
329            "https://fake-domain.nymtech.net",
330            Some(vec![
331                "https://fake-front-1.nymtech.net",
332                "https://fake-front-2.nymtech.net",
333            ]),
334        )
335        .unwrap();
336        let url2 = Url::new(
337            "https://validator.global.ssl.fastly.net",
338            Some(vec!["https://yelp.global.ssl.fastly.net"]),
339        )
340        .unwrap(); // fastly
341
342        let client = ClientBuilder::new_with_urls(vec![url1, url2])
343            .expect("bad url")
344            .with_fronting(Some(FrontPolicy::Always))
345            .build()
346            .expect("failed to build client");
347
348        // Check that the initial configuration has the broken domain and front.
349        assert_eq!(
350            client.current_url().as_str(),
351            "https://fake-domain.nymtech.net/",
352        );
353        assert_eq!(
354            client.current_url().front_str(),
355            Some("fake-front-1.nymtech.net"),
356        );
357
358        let result = client
359            .send_request::<_, (), &str, &str>(
360                reqwest::Method::GET,
361                &["api", "v1", "network", "details"],
362                NO_PARAMS,
363                None,
364            )
365            .await;
366        assert!(result.is_err());
367
368        // Check that the host configuration updated the front on error.
369        assert_eq!(
370            client.current_url().as_str(),
371            "https://fake-domain.nymtech.net/",
372        );
373        assert_eq!(
374            client.current_url().front_str(),
375            Some("fake-front-2.nymtech.net"),
376        );
377
378        let result = client
379            .send_request::<_, (), &str, &str>(
380                reqwest::Method::GET,
381                &["api", "v1", "network", "details"],
382                NO_PARAMS,
383                None,
384            )
385            .await;
386        assert!(result.is_err());
387
388        // Check that the host configuration updated the domain and front on error.
389        assert_eq!(
390            client.current_url().as_str(),
391            "https://validator.global.ssl.fastly.net/",
392        );
393        assert_eq!(
394            client.current_url().front_str(),
395            Some("yelp.global.ssl.fastly.net"),
396        );
397
398        let response = client
399            .send_request::<_, (), &str, &str>(
400                reqwest::Method::GET,
401                &["api", "v1", "network", "details"],
402                NO_PARAMS,
403                None,
404            )
405            .await
406            .expect("failed get request");
407
408        assert_eq!(response.status(), 200);
409    }
410}