monero_simple_request_rpc/
lib.rs

1#![cfg_attr(docsrs, feature(doc_auto_cfg))]
2#![doc = include_str!("../README.md")]
3#![deny(missing_docs)]
4
5use core::future::Future;
6use std::{sync::Arc, io::Read, time::Duration};
7
8use tokio::sync::Mutex;
9
10use zeroize::Zeroizing;
11use digest_auth::{WwwAuthenticateHeader, AuthContext};
12use simple_request::{
13  hyper::{StatusCode, header::HeaderValue, Request},
14  Response, Client,
15};
16
17use monero_rpc::{RpcError, Rpc};
18
19const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
20
21#[derive(Clone, Debug)]
22enum Authentication {
23  // If unauthenticated, use a single client
24  Unauthenticated(Client),
25  // If authenticated, use a single client which supports being locked and tracks its nonce
26  // This ensures that if a nonce is requested, another caller doesn't make a request invalidating
27  // it
28  Authenticated {
29    username: Zeroizing<String>,
30    password: Zeroizing<String>,
31    #[allow(clippy::type_complexity)]
32    connection: Arc<Mutex<(Option<(WwwAuthenticateHeader, u64)>, Client)>>,
33  },
34}
35
36/// An HTTP(S) transport for the RPC.
37///
38/// Requires tokio.
39#[derive(Clone, Debug)]
40pub struct SimpleRequestRpc {
41  authentication: Authentication,
42  url: String,
43  request_timeout: Duration,
44}
45
46impl SimpleRequestRpc {
47  fn digest_auth_challenge(
48    response: &Response,
49  ) -> Result<Option<(WwwAuthenticateHeader, u64)>, RpcError> {
50    Ok(if let Some(header) = response.headers().get("www-authenticate") {
51      Some((
52        digest_auth::parse(header.to_str().map_err(|_| {
53          RpcError::InvalidNode("www-authenticate header wasn't a string".to_string())
54        })?)
55        .map_err(|_| RpcError::InvalidNode("invalid digest-auth response".to_string()))?,
56        0,
57      ))
58    } else {
59      None
60    })
61  }
62
63  /// Create a new HTTP(S) RPC connection.
64  ///
65  /// A daemon requiring authentication can be used via including the username and password in the
66  /// URL.
67  pub async fn new(url: String) -> Result<SimpleRequestRpc, RpcError> {
68    Self::with_custom_timeout(url, DEFAULT_TIMEOUT).await
69  }
70
71  /// Create a new HTTP(S) RPC connection with a custom timeout.
72  ///
73  /// A daemon requiring authentication can be used via including the username and password in the
74  /// URL.
75  pub async fn with_custom_timeout(
76    mut url: String,
77    request_timeout: Duration,
78  ) -> Result<SimpleRequestRpc, RpcError> {
79    let authentication = if url.contains('@') {
80      // Parse out the username and password
81      let url_clone = Zeroizing::new(url);
82      let split_url = url_clone.split('@').collect::<Vec<_>>();
83      if split_url.len() != 2 {
84        Err(RpcError::ConnectionError("invalid amount of login specifications".to_string()))?;
85      }
86      let mut userpass = split_url[0];
87      url = split_url[1].to_string();
88
89      // If there was additionally a protocol string, restore that to the daemon URL
90      if userpass.contains("://") {
91        let split_userpass = userpass.split("://").collect::<Vec<_>>();
92        if split_userpass.len() != 2 {
93          Err(RpcError::ConnectionError("invalid amount of protocol specifications".to_string()))?;
94        }
95        url = split_userpass[0].to_string() + "://" + &url;
96        userpass = split_userpass[1];
97      }
98
99      let split_userpass = userpass.split(':').collect::<Vec<_>>();
100      if split_userpass.len() > 2 {
101        Err(RpcError::ConnectionError("invalid amount of passwords".to_string()))?;
102      }
103
104      let client = Client::without_connection_pool(&url)
105        .map_err(|_| RpcError::ConnectionError("invalid URL".to_string()))?;
106      // Obtain the initial challenge, which also somewhat validates this connection
107      let challenge = Self::digest_auth_challenge(
108        &client
109          .request(
110            Request::post(url.clone())
111              .body(vec![].into())
112              .map_err(|e| RpcError::ConnectionError(format!("couldn't make request: {e:?}")))?,
113          )
114          .await
115          .map_err(|e| RpcError::ConnectionError(format!("{e:?}")))?,
116      )?;
117      Authentication::Authenticated {
118        username: Zeroizing::new(split_userpass[0].to_string()),
119        password: Zeroizing::new((*split_userpass.get(1).unwrap_or(&"")).to_string()),
120        connection: Arc::new(Mutex::new((challenge, client))),
121      }
122    } else {
123      Authentication::Unauthenticated(Client::with_connection_pool())
124    };
125
126    Ok(SimpleRequestRpc { authentication, url, request_timeout })
127  }
128}
129
130impl SimpleRequestRpc {
131  async fn inner_post(&self, route: &str, body: Vec<u8>) -> Result<Vec<u8>, RpcError> {
132    let request_fn = |uri| {
133      Request::post(uri)
134        .body(body.clone().into())
135        .map_err(|e| RpcError::ConnectionError(format!("couldn't make request: {e:?}")))
136    };
137
138    async fn body_from_response(response: Response<'_>) -> Result<Vec<u8>, RpcError> {
139      let mut res = Vec::with_capacity(128);
140      response
141        .body()
142        .await
143        .map_err(|e| RpcError::ConnectionError(format!("{e:?}")))?
144        .read_to_end(&mut res)
145        .map_err(|e| RpcError::ConnectionError(format!("{e:?}")))?;
146      Ok(res)
147    }
148
149    for attempt in 0 .. 2 {
150      return Ok(match &self.authentication {
151        Authentication::Unauthenticated(client) => {
152          body_from_response(
153            client
154              .request(request_fn(self.url.clone() + "/" + route)?)
155              .await
156              .map_err(|e| RpcError::ConnectionError(format!("{e:?}")))?,
157          )
158          .await?
159        }
160        Authentication::Authenticated { username, password, connection } => {
161          let mut connection_lock = connection.lock().await;
162
163          let mut request = request_fn("/".to_string() + route)?;
164
165          // If we don't have an auth challenge, obtain one
166          if connection_lock.0.is_none() {
167            connection_lock.0 = Self::digest_auth_challenge(
168              &connection_lock
169                .1
170                .request(request)
171                .await
172                .map_err(|e| RpcError::ConnectionError(format!("{e:?}")))?,
173            )?;
174            request = request_fn("/".to_string() + route)?;
175          }
176
177          // Insert the challenge response, if we have a challenge
178          if let Some((challenge, cnonce)) = connection_lock.0.as_mut() {
179            // Update the cnonce
180            // Overflow isn't a concern as this is a u64
181            *cnonce += 1;
182
183            let mut context = AuthContext::new_post::<_, _, _, &[u8]>(
184              <_ as AsRef<str>>::as_ref(username),
185              <_ as AsRef<str>>::as_ref(password),
186              "/".to_string() + route,
187              None,
188            );
189            context.set_custom_cnonce(hex::encode(cnonce.to_le_bytes()));
190
191            request.headers_mut().insert(
192              "Authorization",
193              HeaderValue::from_str(
194                &challenge
195                  .respond(&context)
196                  .map_err(|_| {
197                    RpcError::InvalidNode("couldn't respond to digest-auth challenge".to_string())
198                  })?
199                  .to_header_string(),
200              )
201              .map_err(|_| {
202                RpcError::InternalError(
203                  "digest-auth challenge response wasn't a valid string for an HTTP header"
204                    .to_string(),
205                )
206              })?,
207            );
208          }
209
210          let response = connection_lock
211            .1
212            .request(request)
213            .await
214            .map_err(|e| RpcError::ConnectionError(format!("{e:?}")));
215
216          let (error, is_stale) = match &response {
217            Err(e) => (Some(e.clone()), false),
218            Ok(response) => (
219              None,
220              if response.status() == StatusCode::UNAUTHORIZED {
221                if let Some(header) = response.headers().get("www-authenticate") {
222                  header
223                    .to_str()
224                    .map_err(|_| {
225                      RpcError::InvalidNode("www-authenticate header wasn't a string".to_string())
226                    })?
227                    .contains("stale")
228                } else {
229                  false
230                }
231              } else {
232                false
233              },
234            ),
235          };
236
237          // If the connection entered an error state, drop the cached challenge as challenges are
238          // per-connection
239          // We don't need to create a new connection as simple-request will for us
240          if error.is_some() || is_stale {
241            connection_lock.0 = None;
242            // If we're not already on our second attempt, move to the next loop iteration
243            // (retrying all of this once)
244            if attempt == 0 {
245              continue;
246            }
247            if let Some(e) = error {
248              Err(e)?
249            } else {
250              debug_assert!(is_stale);
251              Err(RpcError::InvalidNode(
252                "node claimed fresh connection had stale authentication".to_string(),
253              ))?
254            }
255          } else {
256            body_from_response(response.expect("no response yet also no error?")).await?
257          }
258        }
259      });
260    }
261
262    unreachable!()
263  }
264}
265
266impl Rpc for SimpleRequestRpc {
267  fn post(
268    &self,
269    route: &str,
270    body: Vec<u8>,
271  ) -> impl Send + Future<Output = Result<Vec<u8>, RpcError>> {
272    async move {
273      tokio::time::timeout(self.request_timeout, self.inner_post(route, body))
274        .await
275        .map_err(|e| RpcError::ConnectionError(format!("{e:?}")))?
276    }
277  }
278}