monero_simple_request_rpc/
lib.rs1#![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 Unauthenticated(Client),
25 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#[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 pub async fn new(url: String) -> Result<SimpleRequestRpc, RpcError> {
68 Self::with_custom_timeout(url, DEFAULT_TIMEOUT).await
69 }
70
71 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 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 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 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 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 if let Some((challenge, cnonce)) = connection_lock.0.as_mut() {
179 *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 error.is_some() || is_stale {
241 connection_lock.0 = None;
242 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}