1use std::fmt;
4use std::sync::Arc;
5use std::time::Duration;
6
7use reqwest::header::{self, HeaderMap, HeaderValue};
8use reqwest::{Method, StatusCode};
9use serde::Serialize;
10use serde::de::DeserializeOwned;
11use tracing::Instrument;
12use url::Url;
13
14use crate::error::{ApiError, Error, InvalidValue, Result, truncate};
15use crate::ratelimit::{Limiter, RateLimits, ScopeSet};
16
17pub const DEFAULT_BASE_URL: &str = "https://desec.io/api/v1";
19
20pub const DEFAULT_USER_AGENT: &str = concat!("desec-rs/", env!("CARGO_PKG_VERSION"));
22
23pub const MAX_RETRY_AFTER: Duration = Duration::from_secs(86_400);
29
30#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
40#[serde(transparent)]
41pub struct Secret(String);
42
43impl Secret {
44 pub fn new(secret: impl Into<String>) -> Self {
46 Self(secret.into())
47 }
48
49 pub fn expose(&self) -> &str {
51 &self.0
52 }
53}
54
55impl<T: Into<String>> From<T> for Secret {
56 fn from(value: T) -> Self {
57 Self::new(value)
58 }
59}
60
61impl fmt::Debug for Secret {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 f.write_str("Secret(<redacted>)")
64 }
65}
66
67impl fmt::Display for Secret {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 f.write_str("<redacted>")
70 }
71}
72
73#[derive(Debug, Clone, Default)]
75pub(crate) enum Auth {
76 #[default]
78 None,
79 Token(Secret),
81 Basic { username: String, password: Secret },
83}
84
85impl Auth {
86 fn header(&self) -> Option<Result<HeaderValue, InvalidValue>> {
87 let value = match self {
88 Self::None => return None,
89 Self::Token(secret) => format!("Token {}", secret.expose()),
90 Self::Basic { username, password } => {
91 format!(
92 "Basic {}",
93 base64_standard(format!("{username}:{}", password.expose()).as_bytes())
94 )
95 }
96 };
97 Some(HeaderValue::from_str(&value).map_err(|_| {
98 InvalidValue::new(
101 "credential",
102 "contains characters that cannot go in an HTTP header",
103 "<redacted>",
104 )
105 }))
106 }
107}
108
109fn is_replayable(method: &Method) -> bool {
119 matches!(
120 *method,
121 Method::GET | Method::HEAD | Method::PUT | Method::DELETE | Method::OPTIONS
122 )
123}
124
125fn base64_standard(input: &[u8]) -> String {
127 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
128 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
129 for chunk in input.chunks(3) {
130 let b = [
131 chunk[0],
132 chunk.get(1).copied().unwrap_or(0),
133 chunk.get(2).copied().unwrap_or(0),
134 ];
135 let bits = u32::from(b[0]) << 16 | u32::from(b[1]) << 8 | u32::from(b[2]);
136 for i in 0..4 {
137 if i <= chunk.len() {
138 let index = (bits >> (18 - 6 * i)) & 0x3f;
139 out.push(char::from(ALPHABET[index as usize]));
140 } else {
141 out.push('=');
142 }
143 }
144 }
145 out
146}
147
148#[derive(Debug, Clone)]
150pub(crate) struct RetryConfig {
151 pub(crate) max_retries: u32,
153 pub(crate) max_delay: Duration,
156 pub(crate) initial_backoff: Duration,
158}
159
160impl Default for RetryConfig {
161 fn default() -> Self {
162 Self {
163 max_retries: 3,
164 max_delay: Duration::from_secs(60),
165 initial_backoff: Duration::from_millis(500),
166 }
167 }
168}
169
170#[derive(Debug)]
171struct Inner {
172 http: reqwest::Client,
173 base: Url,
174 auth: Auth,
175 limiter: Arc<Limiter>,
179 retry: RetryConfig,
180}
181
182#[derive(Debug, Clone)]
198pub struct Client {
199 inner: Arc<Inner>,
200}
201
202impl Client {
203 pub fn builder() -> ClientBuilder {
205 ClientBuilder::default()
206 }
207
208 pub fn new(token: impl Into<Secret>) -> Result<Self> {
210 Self::builder().token(token).build()
211 }
212
213 pub fn base_url(&self) -> &Url {
215 &self.inner.base
216 }
217
218 pub fn with_token(&self, token: impl Into<Secret>) -> Self {
236 Self {
237 inner: Arc::new(Inner {
238 http: self.inner.http.clone(),
239 base: self.inner.base.clone(),
240 auth: Auth::Token(token.into()),
241 limiter: Arc::clone(&self.inner.limiter),
242 retry: self.inner.retry.clone(),
243 }),
244 }
245 }
246
247 pub(crate) fn url(&self, segments: &[&str]) -> Url {
250 let mut url = self.inner.base.clone();
251 {
252 #[expect(clippy::expect_used)]
254 let mut path = url
255 .path_segments_mut()
256 .expect("base URL was validated as a base");
257 for segment in segments {
258 path.push(segment);
259 }
260 path.push("");
261 }
262 url
263 }
264
265 pub(crate) fn request(&self, method: Method, url: Url, scopes: ScopeSet) -> Req {
266 Req {
267 method,
268 url,
269 body: None,
270 scopes,
271 }
272 }
273
274 pub(crate) async fn send(&self, req: Req) -> Result<Res> {
277 let res = self.execute(req).await?;
278 if res.status.is_client_error() || res.status.is_server_error() {
279 let body = ApiError::parse(&res.text_lossy());
280 return Err(Error::Api {
281 status: res.status,
282 method: res.method,
283 path: res.path,
284 detail: body.to_string(),
285 body,
286 });
287 }
288 Ok(res)
289 }
290
291 pub(crate) async fn send_json<T: DeserializeOwned>(&self, req: Req) -> Result<T> {
293 let res = self.send(req).await?;
294 res.json()
295 }
296
297 pub(crate) async fn send_json_opt<T: DeserializeOwned>(&self, req: Req) -> Result<Option<T>> {
299 match self.send(req).await {
300 Ok(res) => res.json().map(Some),
301 Err(err) if err.is_not_found() => Ok(None),
302 Err(err) => Err(err),
303 }
304 }
305
306 pub(crate) async fn send_empty(&self, req: Req) -> Result<()> {
308 self.send(req).await.map(drop)
309 }
310
311 pub(crate) async fn send_text(&self, req: Req) -> Result<String> {
313 Ok(self.send(req).await?.text_lossy())
314 }
315
316 async fn execute(&self, req: Req) -> Result<Res> {
318 let Req {
319 method,
320 url,
321 body,
322 scopes,
323 } = req;
324 let path = url.path().to_owned();
325
326 let span = tracing::debug_span!(
327 "desec.request",
328 http.method = %method,
329 url.path = %path,
330 );
331
332 async move {
333 let auth = self.inner.auth.header().transpose()?;
336
337 let mut attempt = 0u32;
338 loop {
339 attempt += 1;
340 self.inner.limiter.acquire(&scopes).await?;
341
342 let mut builder = self.inner.http.request(method.clone(), url.clone());
343 if let Some(body) = &body {
344 builder = builder
345 .header(header::CONTENT_TYPE, "application/json")
346 .body(body.clone());
347 }
348 if let Some(auth) = &auth {
349 builder = builder.header(header::AUTHORIZATION, auth.clone());
350 }
351
352 let outcome = match builder.send().await {
353 Ok(response) => {
354 let status = response.status();
355 let headers = response.headers().clone();
356 match response.bytes().await {
357 Ok(bytes) => Ok(Res {
358 status,
359 headers,
360 body: bytes.to_vec(),
361 method: method.clone(),
362 path: path.clone(),
363 url: url.clone(),
364 }),
365 Err(err) => Err(err),
366 }
367 }
368 Err(err) => Err(err),
369 };
370
371 let res = match outcome {
372 Ok(res) => res,
373 Err(err) => {
374 let transient = err.is_timeout() || err.is_connect() || err.is_request();
379 let retryable = transient && is_replayable(&method);
380 let err = Error::transport(err);
383 if retryable && attempt <= self.inner.retry.max_retries {
384 let delay = self.backoff(attempt);
385 tracing::warn!(
386 attempt,
387 delay_ms = delay.as_millis(),
388 error = %err,
389 "request failed, retrying"
390 );
391 tokio::time::sleep(delay).await;
392 continue;
393 }
394 return Err(err);
395 }
396 };
397
398 tracing::debug!(
399 attempt,
400 http.status = res.status.as_u16(),
401 body_bytes = res.body.len(),
402 "response"
403 );
404
405 if res.status == StatusCode::TOO_MANY_REQUESTS {
406 let retry_after = res.retry_after();
407 self.inner.limiter.record_throttled(&scopes, retry_after);
408
409 let delay = retry_after.unwrap_or_else(|| self.backoff(attempt));
410 if attempt > self.inner.retry.max_retries || delay > self.inner.retry.max_delay
411 {
412 tracing::warn!(
413 attempt,
414 retry_after_s = retry_after.map(|d| d.as_secs()),
415 "giving up on a throttled request"
416 );
417 return Err(Error::RateLimited {
418 attempts: attempt,
419 retry_after,
420 body: ApiError::parse(&res.text_lossy()),
421 });
422 }
423 tracing::info!(
424 attempt,
425 delay_ms = delay.as_millis(),
426 "throttled by the server, waiting"
427 );
428 tokio::time::sleep(delay).await;
429 continue;
430 }
431
432 if res.status.is_server_error()
437 && is_replayable(&method)
438 && attempt <= self.inner.retry.max_retries
439 {
440 let delay = self.backoff(attempt);
441 tracing::warn!(
442 attempt,
443 http.status = res.status.as_u16(),
444 delay_ms = delay.as_millis(),
445 "server error, retrying"
446 );
447 tokio::time::sleep(delay).await;
448 continue;
449 }
450
451 return Ok(res);
452 }
453 }
454 .instrument(span)
455 .await
456 }
457
458 fn backoff(&self, attempt: u32) -> Duration {
460 let factor = 1u32 << attempt.min(16).saturating_sub(1);
461 self.inner
462 .retry
463 .initial_backoff
464 .saturating_mul(factor)
465 .min(self.inner.retry.max_delay)
466 }
467}
468
469pub(crate) struct Req {
471 method: Method,
472 url: Url,
473 body: Option<Vec<u8>>,
474 scopes: ScopeSet,
475}
476
477impl Req {
478 pub(crate) fn query(mut self, key: &str, value: &str) -> Self {
480 self.url.query_pairs_mut().append_pair(key, value);
481 self
482 }
483
484 pub(crate) fn json<T: Serialize + ?Sized>(mut self, body: &T) -> Result<Self> {
486 self.body = Some(serde_json::to_vec(body).map_err(Error::Encode)?);
487 Ok(self)
488 }
489
490 pub(crate) fn url_mut(&mut self) -> &mut Url {
491 &mut self.url
492 }
493}
494
495pub(crate) struct Res {
497 pub(crate) status: StatusCode,
498 pub(crate) headers: HeaderMap,
499 pub(crate) body: Vec<u8>,
500 pub(crate) method: Method,
501 pub(crate) path: String,
502 pub(crate) url: Url,
504}
505
506impl Res {
507 pub(crate) fn json<T: DeserializeOwned>(&self) -> Result<T> {
508 serde_json::from_slice(&self.body).map_err(|source| Error::Decode {
509 expected: std::any::type_name::<T>(),
510 body: truncate(&self.text_lossy(), 2048),
511 source,
512 })
513 }
514
515 pub(crate) fn text_lossy(&self) -> String {
516 String::from_utf8_lossy(&self.body).into_owned()
517 }
518
519 pub(crate) fn header(&self, name: header::HeaderName) -> Option<&str> {
520 self.headers.get(name)?.to_str().ok()
521 }
522
523 fn retry_after(&self) -> Option<Duration> {
529 let raw = self.header(header::RETRY_AFTER)?.trim().to_owned();
530 if let Ok(secs) = raw.parse::<u64>() {
531 return Some(Duration::from_secs(secs).min(MAX_RETRY_AFTER));
532 }
533 let deadline = chrono::DateTime::parse_from_rfc2822(&raw).ok()?;
535 let delta = deadline.signed_duration_since(chrono::Utc::now());
536 Some(delta.to_std().ok()?.min(MAX_RETRY_AFTER))
537 }
538}
539
540#[derive(Debug, Default)]
542pub struct ClientBuilder {
543 base: Option<String>,
544 auth: Auth,
545 user_agent: Option<String>,
546 timeout: Option<Duration>,
547 rate_limits: Option<RateLimits>,
548 max_rate_limit_wait: Option<Duration>,
549 retry: RetryConfig,
550 http: Option<reqwest::Client>,
551}
552
553impl ClientBuilder {
554 pub fn token(mut self, token: impl Into<Secret>) -> Self {
557 self.auth = Auth::Token(token.into());
558 self
559 }
560
561 pub fn basic_auth(mut self, username: impl Into<String>, password: impl Into<Secret>) -> Self {
566 self.auth = Auth::Basic {
567 username: username.into(),
568 password: password.into(),
569 };
570 self
571 }
572
573 pub fn base_url(mut self, base: impl Into<String>) -> Self {
577 self.base = Some(base.into());
578 self
579 }
580
581 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
583 self.user_agent = Some(user_agent.into());
584 self
585 }
586
587 pub fn timeout(mut self, timeout: Duration) -> Self {
589 self.timeout = Some(timeout);
590 self
591 }
592
593 pub fn rate_limits(mut self, limits: RateLimits) -> Self {
598 self.rate_limits = Some(limits);
599 self
600 }
601
602 pub fn max_rate_limit_wait(mut self, max_wait: Duration) -> Self {
609 self.max_rate_limit_wait = Some(max_wait);
610 self
611 }
612
613 pub fn max_retries(mut self, retries: u32) -> Self {
616 self.retry.max_retries = retries;
617 self
618 }
619
620 pub fn max_retry_delay(mut self, delay: Duration) -> Self {
625 self.retry.max_delay = delay;
626 self
627 }
628
629 pub fn http_client(mut self, http: reqwest::Client) -> Self {
633 self.http = Some(http);
634 self
635 }
636
637 pub fn build(self) -> Result<Client> {
639 let raw = self.base.as_deref().unwrap_or(DEFAULT_BASE_URL);
640 let mut base = Url::parse(raw)?;
641 if !matches!(base.scheme(), "http" | "https") {
642 return Err(InvalidValue::new("base_url", "must be http or https", raw).into());
643 }
644 {
645 let mut segments = base
647 .path_segments_mut()
648 .map_err(|()| InvalidValue::new("base_url", "cannot be a base URL", raw))?;
649 segments.pop_if_empty();
650 }
651 base.set_query(None);
652 base.set_fragment(None);
653
654 let http = match self.http {
655 Some(http) => http,
656 None => {
657 let mut headers = HeaderMap::new();
658 headers.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
659 let mut builder = reqwest::Client::builder()
660 .user_agent(self.user_agent.as_deref().unwrap_or(DEFAULT_USER_AGENT))
661 .default_headers(headers);
662 if let Some(timeout) = self.timeout {
663 builder = builder.timeout(timeout);
664 }
665 builder.build().map_err(Error::transport)?
666 }
667 };
668
669 let limits = self.rate_limits.unwrap_or_default();
670 let max_wait = self
671 .max_rate_limit_wait
672 .unwrap_or_else(|| Duration::from_secs(60));
673
674 Ok(Client {
675 inner: Arc::new(Inner {
676 http,
677 base,
678 auth: self.auth,
679 limiter: Arc::new(Limiter::new(limits, max_wait)),
680 retry: self.retry,
681 }),
682 })
683 }
684}
685
686#[cfg(test)]
687mod tests {
688 #![allow(clippy::expect_used)]
689
690 use super::*;
691
692 fn client() -> Client {
693 Client::builder()
694 .base_url("https://desec.example/api/v1")
695 .token("secret")
696 .build()
697 .expect("valid configuration")
698 }
699
700 #[test]
701 fn secrets_do_not_leak_through_debug_or_display() {
702 let secret = Secret::new("i-T3b1h_OI-H9ab8tRS98stGtURe");
703 assert_eq!(format!("{secret:?}"), "Secret(<redacted>)");
704 assert_eq!(secret.to_string(), "<redacted>");
705 assert!(!format!("{secret:?} {secret}").contains("T3b1h"));
706 }
707
708 #[test]
709 fn client_debug_does_not_leak_the_token() {
710 let rendered = format!("{:?}", client());
711 assert!(!rendered.contains("secret"), "{rendered}");
712 }
713
714 #[test]
715 fn urls_get_a_trailing_slash_and_no_double_slash() {
716 let client = client();
717 assert_eq!(
718 client.url(&["domains"]).as_str(),
719 "https://desec.example/api/v1/domains/"
720 );
721 assert_eq!(
722 client.url(&["domains", "example.com", "rrsets"]).as_str(),
723 "https://desec.example/api/v1/domains/example.com/rrsets/"
724 );
725 }
726
727 #[test]
728 fn a_trailing_slash_on_the_base_is_normalized_away() {
729 let client = Client::builder()
730 .base_url("https://desec.example/api/v1/")
731 .build()
732 .expect("valid");
733 assert_eq!(
734 client.url(&["domains"]).as_str(),
735 "https://desec.example/api/v1/domains/"
736 );
737 }
738
739 #[test]
741 fn path_segments_are_encoded_without_breaking_dns_syntax() {
742 let client = client();
743 assert_eq!(
744 client
745 .url(&["domains", "example.com", "rrsets", "@", "A"])
746 .as_str(),
747 "https://desec.example/api/v1/domains/example.com/rrsets/@/A/"
748 );
749 assert_eq!(
750 client
751 .url(&["domains", "example.com", "rrsets", "*.wild", "A"])
752 .as_str(),
753 "https://desec.example/api/v1/domains/example.com/rrsets/*.wild/A/"
754 );
755 }
756
757 #[test]
758 fn query_values_are_percent_encoded() {
759 let client = client();
760 let req = client
761 .request(
762 Method::GET,
763 client.url(&["domains", "example.com", "rrsets"]),
764 ScopeSet::default(),
765 )
766 .query("subname", "a b&c=d");
767 assert_eq!(req.url.query(), Some("subname=a+b%26c%3Dd"));
768 }
769
770 #[test]
771 fn rejects_a_non_http_base_url() {
772 let err = Client::builder()
773 .base_url("mailto:someone@example.com")
774 .build()
775 .expect_err("not an http URL");
776 assert!(err.is_validation(), "{err:?}");
777 }
778
779 #[test]
780 fn base64_matches_the_reference_vectors() {
781 assert_eq!(base64_standard(b""), "");
783 assert_eq!(base64_standard(b"f"), "Zg==");
784 assert_eq!(base64_standard(b"fo"), "Zm8=");
785 assert_eq!(base64_standard(b"foo"), "Zm9v");
786 assert_eq!(base64_standard(b"foob"), "Zm9vYg==");
787 assert_eq!(base64_standard(b"fooba"), "Zm9vYmE=");
788 assert_eq!(base64_standard(b"foobar"), "Zm9vYmFy");
789 assert_eq!(base64_standard(b"user:pass"), "dXNlcjpwYXNz");
790 }
791
792 #[test]
793 fn token_auth_uses_the_desec_scheme() {
794 let auth = Auth::Token(Secret::new("abc"));
795 let header = auth
796 .header()
797 .expect("token auth sends a header")
798 .expect("valid header value");
799 assert_eq!(header.to_str().expect("ascii"), "Token abc");
800 }
801
802 #[test]
803 fn basic_auth_is_encoded() {
804 let auth = Auth::Basic {
805 username: "user".into(),
806 password: Secret::new("pass"),
807 };
808 let header = auth
809 .header()
810 .expect("basic auth sends a header")
811 .expect("valid header value");
812 assert_eq!(header.to_str().expect("ascii"), "Basic dXNlcjpwYXNz");
813 }
814
815 #[test]
816 fn backoff_doubles_and_saturates_at_the_ceiling() {
817 let client = Client::builder()
818 .base_url("https://desec.example/api/v1")
819 .max_retry_delay(Duration::from_secs(4))
820 .build()
821 .expect("valid");
822 assert_eq!(client.backoff(1), Duration::from_millis(500));
823 assert_eq!(client.backoff(2), Duration::from_secs(1));
824 assert_eq!(client.backoff(3), Duration::from_secs(2));
825 assert_eq!(client.backoff(4), Duration::from_secs(4));
826 assert_eq!(client.backoff(40), Duration::from_secs(4));
827 }
828}