1use std::sync::Arc;
9use std::time::{Duration, Instant};
10
11use serde::Serialize;
12use serde::de::DeserializeOwned;
13use serde_json::Value;
14use tokio::sync::Semaphore;
15
16use crate::credentials::{Credentials, DEFAULT_TAILNET};
17use crate::error::{ApiError, Idempotence, describe};
18use crate::token::Tokens;
19
20pub const DEFAULT_BASE_URL: &str = "https://api.tailscale.com";
23
24pub const DEFAULT_BUDGET: Duration = Duration::from_secs(30);
26
27pub const DEFAULT_CONCURRENCY: usize = 8;
33
34const UNAUTHORIZED: u16 = 401;
37const TOO_MANY_REQUESTS: u16 = 429;
38
39const MAX_ATTEMPTS: u32 = 4;
41
42const BASE_BACKOFF: Duration = Duration::from_millis(250);
44
45const MAX_BACKOFF: Duration = Duration::from_secs(20);
50
51const MAX_ERROR_BYTES: usize = 8 * 1024;
56
57#[derive(Debug, Clone)]
59pub struct ClientConfig {
60 pub base_url: String,
62 pub tailnet: String,
64 pub credentials: Credentials,
65 pub budget: Duration,
67 pub concurrency: usize,
68 pub max_response_bytes: usize,
70 pub user_agent: String,
71}
72
73impl ClientConfig {
74 pub fn new(credentials: Credentials) -> Self {
76 Self {
77 base_url: DEFAULT_BASE_URL.to_owned(),
78 tailnet: DEFAULT_TAILNET.to_owned(),
79 credentials,
80 budget: DEFAULT_BUDGET,
81 concurrency: DEFAULT_CONCURRENCY,
82 max_response_bytes: 1 << 20,
83 user_agent: format!("tailscale-mcp/{}", env!("CARGO_PKG_VERSION")),
84 }
85 }
86}
87
88#[derive(Debug, Clone)]
94pub struct Client {
95 inner: Arc<Inner>,
96}
97
98#[derive(Debug)]
99struct Inner {
100 http: reqwest::Client,
101 tokens: Tokens,
102 base_url: String,
103 tailnet: String,
104 budget: Duration,
105 max_response_bytes: usize,
106 in_flight: Semaphore,
107}
108
109impl Client {
110 pub fn new(config: ClientConfig) -> Result<Self, ApiError> {
111 let base_url = checked_base_url(&config.base_url)?;
112 if config.concurrency == 0 {
113 return Err(ApiError::Config(
114 "at least one call has to be allowed in flight".to_owned(),
115 ));
116 }
117 if config.max_response_bytes == 0 {
118 return Err(ApiError::Config(
119 "a response size cap of zero would reject every answer".to_owned(),
120 ));
121 }
122
123 let http = reqwest::Client::builder()
124 .user_agent(config.user_agent)
125 .timeout(config.budget)
129 .build()
130 .map_err(|source| {
131 ApiError::Config(format!("the HTTP client could not be built: {source}"))
132 })?;
133
134 Ok(Self {
135 inner: Arc::new(Inner {
136 tokens: Tokens::new(config.credentials, &base_url, http.clone()),
137 http,
138 base_url,
139 tailnet: config.tailnet,
140 budget: config.budget,
141 max_response_bytes: config.max_response_bytes,
142 in_flight: Semaphore::new(config.concurrency),
143 }),
144 })
145 }
146
147 pub fn tailnet(&self) -> &str {
149 &self.inner.tailnet
150 }
151
152 pub fn tailnet_path(&self, tailnet: Option<&str>, rest: &str) -> String {
157 let tailnet = tailnet.map_or(self.tailnet(), str::trim);
158 let tailnet = if tailnet.is_empty() {
159 self.tailnet()
160 } else {
161 tailnet
162 };
163 format!("/api/v2/tailnet/{}{rest}", escape(tailnet))
164 }
165
166 pub fn get(&self, path: impl Into<String>) -> RequestBuilder<'_> {
167 self.request(reqwest::Method::GET, path)
168 }
169
170 pub fn post(&self, path: impl Into<String>) -> RequestBuilder<'_> {
171 self.request(reqwest::Method::POST, path)
172 }
173
174 pub fn put(&self, path: impl Into<String>) -> RequestBuilder<'_> {
175 self.request(reqwest::Method::PUT, path)
176 }
177
178 pub fn patch(&self, path: impl Into<String>) -> RequestBuilder<'_> {
179 self.request(reqwest::Method::PATCH, path)
180 }
181
182 pub fn delete(&self, path: impl Into<String>) -> RequestBuilder<'_> {
183 self.request(reqwest::Method::DELETE, path)
184 }
185
186 fn request(&self, method: reqwest::Method, path: impl Into<String>) -> RequestBuilder<'_> {
187 RequestBuilder {
188 client: self,
189 method,
190 path: path.into(),
191 query: Vec::new(),
192 headers: Vec::new(),
193 body: None,
194 budget: self.inner.budget,
195 broken: None,
196 }
197 }
198}
199
200#[derive(Debug)]
202pub struct RequestBuilder<'a> {
203 client: &'a Client,
204 method: reqwest::Method,
205 path: String,
206 query: Vec<(String, String)>,
207 headers: Vec<(String, String)>,
208 body: Option<Body>,
209 budget: Duration,
210 broken: Option<ApiError>,
213}
214
215#[derive(Debug, Clone)]
220enum Body {
221 Json(Value),
222 Text { content_type: String, text: String },
223}
224
225impl RequestBuilder<'_> {
226 #[must_use]
229 pub fn query(mut self, name: &str, value: impl std::fmt::Display) -> Self {
230 self.query.push((name.to_owned(), value.to_string()));
231 self
232 }
233
234 #[must_use]
236 pub fn maybe_query(self, name: &str, value: Option<impl std::fmt::Display>) -> Self {
237 match value {
238 Some(value) => self.query(name, value),
239 None => self,
240 }
241 }
242
243 #[must_use]
245 pub fn header(mut self, name: &str, value: impl Into<String>) -> Self {
246 self.headers.push((name.to_owned(), value.into()));
247 self
248 }
249
250 #[must_use]
252 pub fn json(mut self, body: &impl Serialize) -> Self {
253 match serde_json::to_value(body) {
254 Ok(value) => self.body = Some(Body::Json(value)),
255 Err(source) => {
256 self.broken.get_or_insert(ApiError::Config(format!(
257 "the request body could not be built: {source}"
258 )));
259 }
260 }
261 self
262 }
263
264 #[must_use]
271 pub fn text(mut self, content_type: &str, body: impl Into<String>) -> Self {
272 self.body = Some(Body::Text {
273 content_type: content_type.to_owned(),
274 text: body.into(),
275 });
276 self
277 }
278
279 #[must_use]
287 pub fn budget(mut self, budget: Duration) -> Self {
288 self.budget = budget;
289 self
290 }
291
292 pub async fn send(self) -> Result<Value, ApiError> {
297 let request = self.describe_request();
298 let answer = self.send_raw().await?;
299 parse(&answer.bytes, &request)
300 }
301
302 pub async fn send_as<T: DeserializeOwned>(self) -> Result<T, ApiError> {
304 Ok(self.send_answer().await?.value)
305 }
306
307 pub async fn send_answer<T: DeserializeOwned>(self) -> Result<Answer<T>, ApiError> {
326 let request = self.describe_request();
327 let answer = self.send_raw().await?;
328 let raw = parse(&answer.bytes, &request)?;
329 let value =
330 T::deserialize(&raw).map_err(|source| ApiError::Malformed { request, source })?;
331 Ok(Answer {
332 value,
333 raw,
334 etag: answer.etag,
335 })
336 }
337
338 pub async fn send_text(self) -> Result<TextBody, ApiError> {
343 let answer = self.send_raw().await?;
344 Ok(TextBody {
345 text: String::from_utf8_lossy(&answer.bytes).into_owned(),
349 etag: answer.etag,
350 })
351 }
352
353 fn describe_request(&self) -> String {
355 format!("{} {}", self.method, self.path)
356 }
357
358 async fn send_raw(self) -> Result<RawBody, ApiError> {
366 let request = self.describe_request();
367 let budget = self.budget;
368 match tokio::time::timeout(budget, self.attempts()).await {
369 Ok(answer) => answer,
370 Err(_) => Err(ApiError::Timeout { request, budget }),
371 }
372 }
373
374 async fn attempts(self) -> Result<RawBody, ApiError> {
376 if let Some(broken) = self.broken {
377 return Err(broken);
378 }
379 let request = self.describe_request();
380 let idempotence = idempotence(&self.method);
381 let deadline = Instant::now() + self.budget;
382 let inner = &self.client.inner;
383 let url = format!("{}{}", inner.base_url, self.path);
384
385 let mut attempt = 0;
386 let mut refreshed = false;
387 loop {
388 attempt += 1;
389 let outcome = self.attempt(&url, &request).await;
390 let error = match outcome {
391 Ok(answer) => return Ok(answer),
392 Err(error) => error,
393 };
394
395 if error.status() == Some(UNAUTHORIZED)
401 && inner.tokens.can_refresh()
402 && !refreshed
403 && attempt < MAX_ATTEMPTS
404 {
405 refreshed = true;
406 tracing::debug!(request = %request, "the token was refused; minting another");
407 continue;
408 }
409
410 let repeatable =
416 idempotence == Idempotence::Repeatable || error.status() == Some(TOO_MANY_REQUESTS);
417 if !error.is_transient() || !repeatable || attempt >= MAX_ATTEMPTS {
418 return Err(error);
419 }
420
421 let delay = backoff(attempt, &error);
422 if Instant::now() + delay >= deadline {
423 return Err(error);
426 }
427 tracing::debug!(
428 request = %request,
429 attempt,
430 delay_ms = delay.as_millis(),
431 because = %error,
432 "retrying a control-plane call"
433 );
434 tokio::time::sleep(delay).await;
435 }
436 }
437
438 async fn attempt(&self, url: &str, request: &str) -> Result<RawBody, ApiError> {
441 let inner = &self.client.inner;
442 let _permit = inner
443 .in_flight
444 .acquire()
445 .await
446 .map_err(|_| ApiError::Config("the client has been shut down".to_owned()))?;
447
448 let bearer = inner.tokens.bearer().await?;
449 let mut sending = inner
450 .http
451 .request(self.method.clone(), url)
452 .bearer_auth(bearer.value.expose())
453 .query(&self.query);
454 for (name, value) in &self.headers {
455 sending = sending.header(name, value);
456 }
457 match &self.body {
458 Some(Body::Json(value)) => sending = sending.json(value),
459 Some(Body::Text { content_type, text }) => {
460 sending = sending
461 .header(reqwest::header::CONTENT_TYPE, content_type)
462 .body(text.clone());
463 }
464 None => {}
465 }
466
467 let response = sending.send().await.map_err(|source| {
468 if source.is_timeout() {
469 ApiError::Timeout {
470 request: request.to_owned(),
471 budget: self.budget,
472 }
473 } else {
474 ApiError::Transport {
475 request: request.to_owned(),
476 source,
477 }
478 }
479 })?;
480
481 let status = response.status();
482 if status.is_success() {
483 return read_body(response, request, inner.max_response_bytes).await;
484 }
485
486 if status.as_u16() == UNAUTHORIZED
487 && let Some(generation) = bearer.generation
488 {
489 inner.tokens.evict(generation).await;
493 }
494
495 let retry_after = retry_after(&response);
496 let body = read_body(response, request, MAX_ERROR_BYTES)
497 .await
498 .map(|raw| String::from_utf8_lossy(&raw.bytes).into_owned())
499 .unwrap_or_default();
500 Err(ApiError::Status {
501 request: request.to_owned(),
502 status: status.as_u16(),
503 message: describe(status, &body),
504 retry_after,
505 })
506 }
507}
508
509#[derive(Debug)]
511struct RawBody {
512 bytes: Vec<u8>,
513 etag: Option<String>,
514}
515
516fn parse(bytes: &[u8], request: &str) -> Result<Value, ApiError> {
521 if bytes.iter().all(u8::is_ascii_whitespace) {
522 return Ok(Value::Null);
523 }
524 serde_json::from_slice(bytes).map_err(|source| ApiError::Malformed {
525 request: request.to_owned(),
526 source,
527 })
528}
529
530#[derive(Debug, Clone)]
537pub struct Answer<T> {
538 pub value: T,
539 pub raw: Value,
541 pub etag: Option<String>,
543}
544
545#[derive(Debug, Clone)]
547pub struct TextBody {
548 pub text: String,
549 pub etag: Option<String>,
551}
552
553async fn read_body(
561 mut response: reqwest::Response,
562 request: &str,
563 cap: usize,
564) -> Result<RawBody, ApiError> {
565 let etag = response
566 .headers()
567 .get(reqwest::header::ETAG)
568 .and_then(|value| value.to_str().ok())
569 .map(str::to_owned);
570
571 let too_large = || ApiError::TooLarge {
572 request: request.to_owned(),
573 cap,
574 };
575 if response
576 .content_length()
577 .is_some_and(|len| len > cap as u64)
578 {
579 return Err(too_large());
580 }
581
582 let mut bytes = Vec::new();
583 while let Some(chunk) = response
584 .chunk()
585 .await
586 .map_err(|source| ApiError::Transport {
587 request: request.to_owned(),
588 source,
589 })?
590 {
591 if bytes.len() + chunk.len() > cap {
592 return Err(too_large());
593 }
594 bytes.extend_from_slice(&chunk);
595 }
596 Ok(RawBody { bytes, etag })
597}
598
599fn idempotence(method: &reqwest::Method) -> Idempotence {
605 match *method {
606 reqwest::Method::POST | reqwest::Method::PATCH => Idempotence::Once,
607 _ => Idempotence::Repeatable,
608 }
609}
610
611fn backoff(attempt: u32, error: &ApiError) -> Duration {
618 if let ApiError::Status {
619 retry_after: Some(asked),
620 ..
621 } = error
622 {
623 return (*asked).min(MAX_BACKOFF);
624 }
625 (BASE_BACKOFF * 2u32.saturating_pow(attempt - 1)).min(MAX_BACKOFF)
626}
627
628fn retry_after(response: &reqwest::Response) -> Option<Duration> {
633 response
634 .headers()
635 .get(reqwest::header::RETRY_AFTER)?
636 .to_str()
637 .ok()?
638 .trim()
639 .parse::<u64>()
640 .ok()
641 .map(Duration::from_secs)
642}
643
644pub fn checked_base_url(base_url: &str) -> Result<String, ApiError> {
657 let trimmed = base_url.trim().trim_end_matches('/');
658 let parsed = reqwest::Url::parse(trimmed)
659 .map_err(|source| ApiError::Config(format!("`{base_url}` is not a URL: {source}")))?;
660
661 let loopback = parsed.host_str().is_some_and(|host| {
662 let host = host.trim_start_matches('[').trim_end_matches(']');
665 host.eq_ignore_ascii_case("localhost")
666 || host
667 .parse::<std::net::IpAddr>()
668 .is_ok_and(|address| address.is_loopback())
669 });
670 if parsed.scheme() != "https" && !loopback {
671 return Err(ApiError::Config(format!(
672 "`{base_url}` is neither https nor a loopback address, and a \
673 control-plane credential is not sent anywhere else"
674 )));
675 }
676 if !parsed.path().is_empty() && parsed.path() != "/" {
677 return Err(ApiError::Config(format!(
678 "`{base_url}` has a path; the base URL is a host and nothing more"
679 )));
680 }
681 if !parsed.username().is_empty() || parsed.password().is_some() {
685 return Err(ApiError::Config(
686 "the base URL carries a username or password; a control-plane \
687 credential is sent as a header and never in a URL"
688 .to_owned(),
689 ));
690 }
691 Ok(trimmed.to_owned())
692}
693
694fn escape(segment: &str) -> String {
701 let mut out = String::with_capacity(segment.len());
702 for byte in segment.bytes() {
703 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~' | b'@') {
704 out.push(char::from(byte));
705 } else {
706 out.push_str(&format!("%{byte:02X}"));
707 }
708 }
709 out
710}
711
712#[cfg(test)]
713mod tests {
714 use std::collections::BTreeSet;
715 use std::path::PathBuf;
716
717 use serde_json::json;
718
719 use super::*;
720 use crate::fake::{FakeControlPlane, Response};
721 use crate::secret::Secret;
722
723 const KEY: &str = "tskey-api-redacted-example";
725 const DEVICES: &str = "/api/v2/tailnet/-/devices";
726
727 async fn fake() -> FakeControlPlane {
728 FakeControlPlane::start()
729 .await
730 .expect("a loopback socket is available")
731 }
732
733 fn client(fake: &FakeControlPlane, credentials: Credentials) -> Client {
734 client_with(fake, credentials, |_| {})
735 }
736
737 fn client_with(
738 fake: &FakeControlPlane,
739 credentials: Credentials,
740 adjust: impl FnOnce(&mut ClientConfig),
741 ) -> Client {
742 let mut config = ClientConfig::new(credentials);
743 config.base_url = fake.base_url().to_owned();
744 adjust(&mut config);
745 Client::new(config).expect("the fake answers on a loopback address")
746 }
747
748 fn api_key() -> Credentials {
749 Credentials::ApiKey(Secret::new(KEY))
750 }
751
752 fn oauth() -> Credentials {
753 Credentials::OauthClient {
754 client_id: "kExAmPlE1CNTRL".to_owned(),
755 client_secret: Secret::new("tskey-client-redacted-example"),
756 scopes: vec!["devices:read".to_owned(), "dns".to_owned()],
757 }
758 }
759
760 fn token(value: &str, seconds: u64) -> Response {
762 Response::json(json!({
763 "access_token": value,
764 "token_type": "Bearer",
765 "expires_in": seconds,
766 }))
767 }
768
769 fn bearers(fake: &FakeControlPlane) -> Vec<String> {
771 fake.recorded()
772 .into_iter()
773 .filter_map(|r| r.authorization().map(str::to_owned))
774 .collect()
775 }
776
777 #[tokio::test]
780 async fn an_api_key_is_the_bearer_token_itself() {
781 let fake = fake()
782 .await
783 .on("GET", DEVICES, Response::json(json!({"devices": []})));
784 let client = client(&fake, api_key());
785
786 let answer = client.get(DEVICES).send().await.expect("the fake answers");
787
788 assert_eq!(answer, json!({"devices": []}));
789 let request = fake.only_request();
791 assert_eq!(
792 request.authorization(),
793 Some(format!("Bearer {KEY}").as_str())
794 );
795 }
796
797 #[tokio::test]
798 async fn an_oauth_client_is_exchanged_for_a_token() {
799 let fake = fake()
800 .await
801 .on("POST", crate::token::TOKEN_PATH, token("minted-1", 3600))
802 .on("GET", DEVICES, Response::json(json!({"devices": []})));
803 let client = client(&fake, oauth());
804
805 client.get(DEVICES).send().await.expect("the fake answers");
806
807 let recorded = fake.recorded();
808 assert_eq!(
809 recorded.len(),
810 2,
811 "an exchange and then the call: {recorded:#?}"
812 );
813 let exchange = &recorded[0];
814 assert_eq!(exchange.path, crate::token::TOKEN_PATH);
815 for expected in [
816 "grant_type=client_credentials",
817 "client_id=kExAmPlE1CNTRL",
818 "client_secret=tskey-client-redacted-example",
819 "scope=devices%3Aread+dns",
822 ] {
823 assert!(
824 exchange.body.contains(expected),
825 "the exchange did not send `{expected}`: {}",
826 exchange.body
827 );
828 }
829 assert_eq!(recorded[1].authorization(), Some("Bearer minted-1"));
830 }
831
832 #[tokio::test]
833 async fn a_federated_identity_signs_with_the_jwt_on_disk() {
834 let directory = tempfile::tempdir().expect("a temporary directory");
835 let jwt_file = directory.path().join("token");
836 std::fs::write(&jwt_file, "header.payload.signature\n").expect("the file is written");
837
838 let fake = fake()
839 .await
840 .on(
841 "POST",
842 crate::token::TOKEN_PATH,
843 token("minted-federated", 3600),
844 )
845 .on("GET", DEVICES, Response::json(json!({"devices": []})));
846 let client = client(
847 &fake,
848 Credentials::Federated {
849 client_id: Some("kExAmPlE1CNTRL".to_owned()),
850 jwt_file,
851 scopes: Vec::new(),
852 },
853 );
854
855 client.get(DEVICES).send().await.expect("the fake answers");
856
857 let exchange = &fake.recorded()[0];
858 assert!(
859 exchange
860 .body
861 .contains("client_assertion=header.payload.signature"),
862 "the JWT was not sent, or was sent with its trailing newline: {}",
863 exchange.body
864 );
865 assert!(
866 exchange.body.contains("client_assertion_type=urn%3Aietf"),
867 "the assertion type was not sent: {}",
868 exchange.body
869 );
870 }
871
872 #[tokio::test]
873 async fn a_missing_jwt_file_says_which_file_it_was() {
874 let fake = fake().await;
875 let client = client(
876 &fake,
877 Credentials::Federated {
878 client_id: None,
879 jwt_file: PathBuf::from("/nonexistent/identity/token"),
880 scopes: Vec::new(),
881 },
882 );
883
884 let error = client
885 .get(DEVICES)
886 .send()
887 .await
888 .expect_err("there is no file");
889
890 assert!(
891 matches!(&error, ApiError::JwtFile { path, .. } if path.ends_with("token")),
892 "unexpected error: {error:?}"
893 );
894 assert_eq!(fake.request_count(), 0, "nothing should have been sent");
895 }
896
897 #[tokio::test]
898 async fn the_credential_with_precedence_is_the_one_that_is_used() {
899 let environment = |key: &str| match key {
902 crate::credentials::API_KEY_ENV => Some(KEY.to_owned()),
903 crate::credentials::OAUTH_CLIENT_ID_ENV => Some("kExAmPlE1CNTRL".to_owned()),
904 crate::credentials::OAUTH_CLIENT_SECRET_ENV => Some("unused".to_owned()),
905 _ => None,
906 };
907 let credentials = Credentials::from_source(environment).expect("both are set");
908
909 let fake = fake().await.on("GET", DEVICES, Response::json(json!({})));
910 let client = client(&fake, credentials);
911 client.get(DEVICES).send().await.expect("the fake answers");
912
913 let request = fake.only_request();
914 assert_eq!(
915 request.authorization(),
916 Some(format!("Bearer {KEY}").as_str())
917 );
918 }
919
920 #[tokio::test]
923 async fn a_token_is_minted_once_and_reused() {
924 let fake = fake()
925 .await
926 .on("POST", crate::token::TOKEN_PATH, token("minted-1", 3600))
927 .on("GET", DEVICES, Response::json(json!({})));
928 let client = client(&fake, oauth());
929
930 for _ in 0..3 {
931 client.get(DEVICES).send().await.expect("the fake answers");
932 }
933
934 let exchanges = fake
935 .recorded()
936 .iter()
937 .filter(|r| r.path == crate::token::TOKEN_PATH)
938 .count();
939 assert_eq!(exchanges, 1, "the token should have been minted once");
940 assert_eq!(bearers(&fake), vec!["Bearer minted-1".to_owned(); 3]);
943 }
944
945 #[tokio::test]
946 async fn a_token_near_its_expiry_is_minted_again() {
947 let remaining = crate::token::REFRESH_SKEW.as_secs() / 2;
952 let fake = fake()
953 .await
954 .on(
955 "POST",
956 crate::token::TOKEN_PATH,
957 token("minted-1", remaining),
958 )
959 .on("GET", DEVICES, Response::json(json!({})));
960 let client = client(&fake, oauth());
961
962 for _ in 0..2 {
963 client.get(DEVICES).send().await.expect("the fake answers");
964 }
965
966 let exchanges = fake
967 .recorded()
968 .iter()
969 .filter(|r| r.path == crate::token::TOKEN_PATH)
970 .count();
971 assert_eq!(exchanges, 2, "a token inside the skew should not be reused");
972 }
973
974 #[tokio::test]
975 async fn a_refused_token_is_replaced_exactly_once() {
976 let fake = fake()
977 .await
978 .once("POST", crate::token::TOKEN_PATH, token("stale", 3600))
979 .on("POST", crate::token::TOKEN_PATH, token("fresh", 3600))
980 .once(
981 "GET",
982 DEVICES,
983 Response::status(401, json!({"message": "expired"})),
984 )
985 .on("GET", DEVICES, Response::json(json!({"devices": []})));
986 let client = client(&fake, oauth());
987
988 let answer = client
989 .get(DEVICES)
990 .send()
991 .await
992 .expect("the second try works");
993
994 assert_eq!(answer, json!({"devices": []}));
995 assert_eq!(
996 bearers(&fake),
997 vec!["Bearer stale".to_owned(), "Bearer fresh".to_owned()],
998 "the refused token should have been replaced, once"
999 );
1000 }
1001
1002 #[tokio::test]
1003 async fn a_token_refused_twice_is_the_credential_being_wrong() {
1004 let fake = fake()
1005 .await
1006 .on("POST", crate::token::TOKEN_PATH, token("minted", 3600))
1007 .on(
1008 "GET",
1009 DEVICES,
1010 Response::status(401, json!({"message": "no"})),
1011 );
1012 let client = client(&fake, oauth());
1013
1014 let error = client
1015 .get(DEVICES)
1016 .send()
1017 .await
1018 .expect_err("it is always refused");
1019
1020 assert_eq!(error.status(), Some(401));
1021 let calls = fake.recorded().iter().filter(|r| r.path == DEVICES).count();
1022 assert_eq!(calls, 2, "one retry with a fresh token, and then no more");
1023 }
1024
1025 #[tokio::test]
1026 async fn a_refused_api_key_is_not_replaced_because_there_is_nothing_to_mint() {
1027 let fake = fake().await.on(
1028 "GET",
1029 DEVICES,
1030 Response::status(401, json!({"message": "no"})),
1031 );
1032 let client = client(&fake, api_key());
1033
1034 let error = client.get(DEVICES).send().await.expect_err("it is refused");
1035
1036 assert_eq!(error.status(), Some(401));
1037 assert_eq!(fake.request_count(), 1);
1038 }
1039
1040 #[tokio::test]
1043 async fn a_transient_failure_on_a_repeatable_method_is_retried() {
1044 let fake = fake()
1045 .await
1046 .once(
1047 "GET",
1048 DEVICES,
1049 Response::status(503, json!({"message": "later"})),
1050 )
1051 .on("GET", DEVICES, Response::json(json!({"devices": []})));
1052 let client = client(&fake, api_key());
1053
1054 let answer = client
1055 .get(DEVICES)
1056 .send()
1057 .await
1058 .expect("the second try works");
1059
1060 assert_eq!(answer, json!({"devices": []}));
1061 assert_eq!(fake.request_count(), 2);
1062 }
1063
1064 #[tokio::test]
1065 async fn a_transient_failure_on_an_unsafe_method_is_not_retried() {
1066 let keys = "/api/v2/tailnet/-/keys";
1069 let fake = fake().await.on(
1070 "POST",
1071 keys,
1072 Response::status(503, json!({"message": "later"})),
1073 );
1074 let client = client(&fake, api_key());
1075
1076 let error = client
1077 .post(keys)
1078 .json(&json!({"capabilities": {}}))
1079 .send()
1080 .await
1081 .expect_err("the fake never succeeds");
1082
1083 assert_eq!(error.status(), Some(503));
1084 assert_eq!(fake.request_count(), 1, "a POST must not be sent twice");
1085 }
1086
1087 #[tokio::test]
1088 async fn a_rate_limit_is_retried_even_on_an_unsafe_method() {
1089 let keys = "/api/v2/tailnet/-/keys";
1092 let fake = fake()
1093 .await
1094 .once(
1095 "POST",
1096 keys,
1097 Response::status(429, json!({"message": "slow down"}))
1098 .with_header("retry-after", "0"),
1099 )
1100 .on(
1101 "POST",
1102 keys,
1103 Response::json(json!({"key": "tskey-auth-redacted-example"})),
1104 );
1105 let client = client(&fake, api_key());
1106
1107 let answer = client
1108 .post(keys)
1109 .json(&json!({"capabilities": {}}))
1110 .send()
1111 .await
1112 .expect("the second try works");
1113
1114 assert_eq!(answer["key"], json!("tskey-auth-redacted-example"));
1115 assert_eq!(fake.request_count(), 2);
1116 }
1117
1118 #[tokio::test]
1119 async fn a_permanent_failure_is_not_retried() {
1120 let fake = fake().await.on(
1121 "GET",
1122 DEVICES,
1123 Response::status(404, json!({"message": "no such tailnet"})),
1124 );
1125 let client = client(&fake, api_key());
1126
1127 let error = client
1128 .get(DEVICES)
1129 .send()
1130 .await
1131 .expect_err("there is nothing there");
1132
1133 assert!(
1134 matches!(&error, ApiError::Status { message, .. } if message == "no such tailnet"),
1135 "the API's own message should be passed on: {error:?}"
1136 );
1137 assert_eq!(fake.request_count(), 1);
1138 }
1139
1140 #[tokio::test]
1141 async fn a_call_stops_after_a_bounded_number_of_attempts() {
1142 let fake = fake().await.on(
1143 "GET",
1144 DEVICES,
1145 Response::status(503, json!({"message": "later"})).with_header("retry-after", "0"),
1146 );
1147 let client = client(&fake, api_key());
1148
1149 let error = client
1150 .get(DEVICES)
1151 .send()
1152 .await
1153 .expect_err("it never works");
1154
1155 assert_eq!(error.status(), Some(503));
1156 assert_eq!(fake.request_count(), MAX_ATTEMPTS as usize);
1157 }
1158
1159 #[tokio::test]
1160 async fn retrying_stops_when_the_budget_would_not_cover_the_wait() {
1161 let fake = fake().await.on(
1164 "GET",
1165 DEVICES,
1166 Response::status(503, json!({"message": "later"})),
1167 );
1168 let client = client(&fake, api_key());
1169
1170 let error = client
1171 .get(DEVICES)
1172 .budget(Duration::from_millis(50))
1173 .send()
1174 .await
1175 .expect_err("it never works");
1176
1177 assert_eq!(error.status(), Some(503), "not a bare timeout: {error:?}");
1178 assert_eq!(
1179 fake.request_count(),
1180 1,
1181 "the first backoff is longer than the budget"
1182 );
1183 }
1184
1185 #[tokio::test]
1186 async fn the_wait_a_server_asks_for_is_read_off_the_wire() {
1187 let budget = Duration::from_secs(1);
1197 let refusal = |wait| {
1198 Response::status(503, json!({"message": "later"})).with_header("retry-after", wait)
1199 };
1200
1201 let patient = fake().await.on("GET", DEVICES, refusal("300"));
1202 let error = client(&patient, api_key())
1203 .get(DEVICES)
1204 .budget(budget)
1205 .send()
1206 .await
1207 .expect_err("it never works");
1208 assert_eq!(error.status(), Some(503), "not a bare timeout: {error:?}");
1209 assert_eq!(
1210 patient.request_count(),
1211 1,
1212 "the server asked for longer than the budget, so there was no second try; \
1213 ignoring the header would have waited {BASE_BACKOFF:?} and tried again"
1214 );
1215
1216 let impatient = fake().await.on("GET", DEVICES, refusal("0"));
1217 client(&impatient, api_key())
1218 .get(DEVICES)
1219 .budget(budget)
1220 .send()
1221 .await
1222 .expect_err("it never works");
1223 assert_eq!(
1224 impatient.request_count(),
1225 MAX_ATTEMPTS as usize,
1226 "a server asking for no wait at all should be believed too"
1227 );
1228 }
1229
1230 #[test]
1231 fn the_server_is_believed_about_when_to_come_back() {
1232 let asked = |seconds| ApiError::Status {
1233 request: "GET /x".to_owned(),
1234 status: 429,
1235 message: String::new(),
1236 retry_after: Some(Duration::from_secs(seconds)),
1237 };
1238 let guessed = ApiError::Status {
1240 request: "GET /x".to_owned(),
1241 status: 503,
1242 message: String::new(),
1243 retry_after: None,
1244 };
1245
1246 assert_eq!(backoff(1, &asked(5)), Duration::from_secs(5));
1247 assert_eq!(backoff(1, &asked(600)), MAX_BACKOFF);
1250 assert_eq!(backoff(1, &guessed), BASE_BACKOFF);
1252 assert_eq!(backoff(2, &guessed), BASE_BACKOFF * 2);
1253 assert_eq!(backoff(30, &guessed), MAX_BACKOFF);
1254 }
1255
1256 #[test]
1257 fn only_the_methods_http_calls_idempotent_may_be_repeated() {
1258 for method in [
1259 reqwest::Method::GET,
1260 reqwest::Method::HEAD,
1261 reqwest::Method::PUT,
1262 reqwest::Method::DELETE,
1263 ] {
1264 assert_eq!(idempotence(&method), Idempotence::Repeatable, "{method}");
1265 }
1266 for method in [reqwest::Method::POST, reqwest::Method::PATCH] {
1267 assert_eq!(idempotence(&method), Idempotence::Once, "{method}");
1268 }
1269 }
1270
1271 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1274 async fn no_more_calls_are_in_flight_than_the_limit_allows() {
1275 const LIMIT: usize = 2;
1276 let fake = fake().await.on(
1277 "GET",
1278 DEVICES,
1279 Response::json(json!({})).slow(Duration::from_millis(80)),
1280 );
1281 let client = client_with(&fake, api_key(), |config| config.concurrency = LIMIT);
1282
1283 let calls: Vec<_> = (0..8)
1284 .map(|_| {
1285 let client = client.clone();
1286 tokio::spawn(async move { client.get(DEVICES).send().await })
1287 })
1288 .collect();
1289 for call in calls {
1290 call.await
1291 .expect("the task finished")
1292 .expect("the fake answers");
1293 }
1294
1295 assert_eq!(fake.request_count(), 8);
1296 let peak = fake.peak_concurrency();
1297 assert!(
1298 (1..=LIMIT).contains(&peak),
1299 "{peak} calls were in flight at once, and the limit is {LIMIT}"
1300 );
1301 }
1302
1303 #[tokio::test]
1306 async fn an_answer_over_the_cap_is_refused_rather_than_truncated() {
1307 let big = json!({"devices": vec![json!({"name": "x".repeat(200)})]});
1308 let fake = fake().await.on("GET", DEVICES, Response::json(&big));
1309 let client = client_with(&fake, api_key(), |config| config.max_response_bytes = 64);
1310
1311 let error = client
1312 .get(DEVICES)
1313 .send()
1314 .await
1315 .expect_err("it is too large");
1316
1317 assert!(
1318 matches!(error, ApiError::TooLarge { cap: 64, .. }),
1319 "a truncated body would have failed to parse instead: {error:?}"
1320 );
1321 }
1322
1323 #[tokio::test]
1324 async fn an_answer_with_no_stated_length_is_refused_while_it_is_read() {
1325 let big = json!({"devices": vec![json!({"name": "x".repeat(200)})]});
1328 let fake = fake()
1329 .await
1330 .on("GET", DEVICES, Response::json(&big).chunked());
1331 let client = client_with(&fake, api_key(), |config| config.max_response_bytes = 64);
1332
1333 let error = client
1334 .get(DEVICES)
1335 .send()
1336 .await
1337 .expect_err("it is too large");
1338
1339 assert!(
1340 matches!(error, ApiError::TooLarge { cap: 64, .. }),
1341 "unexpected error: {error:?}"
1342 );
1343 }
1344
1345 #[tokio::test]
1346 async fn an_answer_under_the_cap_arrives_whole_however_it_is_framed() {
1347 let body = json!({"devices": [{"name": "workstation"}]});
1348 let fake = fake()
1349 .await
1350 .on("GET", DEVICES, Response::json(&body).chunked());
1351 let client = client(&fake, api_key());
1352
1353 let answer = client.get(DEVICES).send().await.expect("the fake answers");
1354
1355 assert_eq!(answer, body);
1356 }
1357
1358 #[tokio::test]
1361 async fn an_empty_body_is_an_answer_rather_than_a_parse_failure() {
1362 let device = "/api/v2/device/n1111111CNTRL";
1363 let fake = fake().await.on("DELETE", device, Response::empty());
1364 let client = client(&fake, api_key());
1365
1366 let answer = client
1367 .delete(device)
1368 .send()
1369 .await
1370 .expect("the fake answers");
1371
1372 assert_eq!(answer, Value::Null, "a deletion answers with nothing");
1373 }
1374
1375 #[tokio::test]
1376 async fn an_answer_is_read_both_ways_from_one_parse() {
1377 let body = json!({
1382 "id": "kExAmPlE",
1383 "description": "a key",
1384 "invented": {"by": "a later control plane"},
1385 });
1386 let keys = "/api/v2/tailnet/-/keys/kExAmPlE";
1387 let fake = fake().await.on("GET", keys, Response::json(&body));
1388 let client = client(&fake, api_key());
1389
1390 let answer = client
1391 .get(keys)
1392 .send_answer::<crate::models::key::Key>()
1393 .await
1394 .expect("the fake answers");
1395
1396 assert_eq!(answer.value.id.as_deref(), Some("kExAmPlE"));
1397 assert_eq!(
1398 answer.value.unknown.get("invented"),
1399 Some(&json!({"by": "a later control plane"})),
1400 "the typed half keeps what it had no field for"
1401 );
1402 assert_eq!(answer.raw, body, "and the raw half is the body, untouched");
1403 }
1404
1405 #[tokio::test]
1406 async fn an_answer_carries_the_etag_that_versions_it() {
1407 let acl = "/api/v2/tailnet/-/acl";
1410 let fake = fake().await.on(
1411 "GET",
1412 acl,
1413 Response::json(json!({"acls": []})).with_header("ETag", "\"abc123\""),
1414 );
1415 let client = client(&fake, api_key());
1416
1417 let answer = client
1418 .get(acl)
1419 .send_answer::<Value>()
1420 .await
1421 .expect("the fake answers");
1422
1423 assert_eq!(answer.etag.as_deref(), Some("\"abc123\""));
1424 }
1425
1426 #[tokio::test]
1427 async fn an_empty_body_answers_as_nothing_rather_than_failing_to_parse() {
1428 let device = "/api/v2/device/n1111111CNTRL";
1432 let fake = fake().await.on("DELETE", device, Response::empty());
1433 let client = client(&fake, api_key());
1434
1435 let answer = client
1436 .delete(device)
1437 .send_answer::<Value>()
1438 .await
1439 .expect("the fake answers");
1440
1441 assert_eq!(answer.value, Value::Null);
1442 assert_eq!(answer.raw, Value::Null, "both halves agree about nothing");
1443 }
1444
1445 #[tokio::test]
1446 async fn the_query_and_the_body_reach_the_control_plane_as_written() {
1447 let fake = fake().await.on("POST", DEVICES, Response::json(json!({})));
1448 let client = client(&fake, api_key());
1449
1450 client
1451 .post(DEVICES)
1452 .query("fields", "all")
1453 .maybe_query("since", Some(7))
1454 .maybe_query("until", Option::<u8>::None)
1455 .header("If-Match", "\"v1\"")
1456 .json(&json!({"name": "workstation"}))
1457 .send()
1458 .await
1459 .expect("the fake answers");
1460
1461 let request = fake.only_request();
1462 assert_eq!(
1463 request
1464 .query
1465 .keys()
1466 .map(String::as_str)
1467 .collect::<BTreeSet<_>>(),
1468 BTreeSet::from(["fields", "since"]),
1469 "an absent parameter should not be sent"
1470 );
1471 assert_eq!(request.query["fields"], "all");
1472 assert_eq!(
1473 request.headers.get("if-match").map(String::as_str),
1474 Some("\"v1\"")
1475 );
1476 assert_eq!(request.json(), json!({"name": "workstation"}));
1477 }
1478
1479 #[tokio::test]
1480 async fn text_comes_back_with_the_version_it_was_read_at() {
1481 let policy = "/api/v2/tailnet/-/acl";
1484 let hujson = "{\n // a comment, which JSON does not have\n \"acls\": [],\n}";
1485 let fake = fake().await.on(
1486 "GET",
1487 policy,
1488 Response {
1489 status: 200,
1490 headers: vec![("content-type".to_owned(), "application/hujson".to_owned())],
1491 body: hujson.to_owned(),
1492 delay: Duration::ZERO,
1493 chunked: false,
1494 }
1495 .with_header("etag", "\"abc123\""),
1496 );
1497 let client = client(&fake, api_key());
1498
1499 let answer = client
1500 .get(policy)
1501 .send_text()
1502 .await
1503 .expect("the fake answers");
1504
1505 assert_eq!(answer.text, hujson);
1506 assert_eq!(answer.etag.as_deref(), Some("\"abc123\""));
1507 }
1508
1509 #[tokio::test]
1510 async fn a_body_that_is_not_what_was_asked_for_says_so() {
1511 #[derive(Debug, serde::Deserialize)]
1512 struct Devices {
1513 #[allow(dead_code)]
1514 devices: Vec<String>,
1515 }
1516 let fake = fake()
1517 .await
1518 .on("GET", DEVICES, Response::json(json!({"devices": 7})));
1519 let client = client(&fake, api_key());
1520
1521 let error = client
1522 .get(DEVICES)
1523 .send_as::<Devices>()
1524 .await
1525 .expect_err("seven is not a list");
1526
1527 assert!(
1528 matches!(&error, ApiError::Malformed { request, .. } if request == "GET /api/v2/tailnet/-/devices"),
1529 "unexpected error: {error:?}"
1530 );
1531 }
1532
1533 #[test]
1536 fn a_base_url_is_an_encrypted_host_and_nothing_more() {
1537 for allowed in [
1542 DEFAULT_BASE_URL,
1543 "https://api.example.com",
1544 "https://example.com",
1545 "http://127.0.0.1:8080",
1546 "http://localhost:9999",
1547 "http://[::1]:1234",
1548 ] {
1549 assert!(
1550 checked_base_url(allowed).is_ok(),
1551 "{allowed} should have been accepted"
1552 );
1553 }
1554 for refused in [
1555 "http://api.tailscale.com",
1558 "http://evil.example.com",
1559 "api.tailscale.com",
1561 "ftp://api.tailscale.com",
1562 "https://api.tailscale.com/api/v2",
1565 "https://user:pass@example.com",
1570 "https://token@example.com",
1571 ] {
1572 assert!(
1573 checked_base_url(refused).is_err(),
1574 "{refused} should have been refused"
1575 );
1576 }
1577 assert_eq!(
1580 checked_base_url("https://api.tailscale.com/").expect("a valid URL"),
1581 DEFAULT_BASE_URL
1582 );
1583 }
1584
1585 #[test]
1586 fn a_name_in_a_path_cannot_reach_into_the_path_around_it() {
1587 let fake_config = ClientConfig::new(api_key());
1588 let client = Client::new(fake_config).expect("the default base URL is valid");
1589
1590 assert_eq!(
1591 client.tailnet_path(None, "/devices"),
1592 "/api/v2/tailnet/-/devices"
1593 );
1594 assert_eq!(
1595 client.tailnet_path(Some("example.com"), "/dns/nameservers"),
1596 "/api/v2/tailnet/example.com/dns/nameservers"
1597 );
1598 assert_eq!(
1600 client.tailnet_path(Some(" "), "/devices"),
1601 "/api/v2/tailnet/-/devices"
1602 );
1603 assert_eq!(
1605 client.tailnet_path(Some("../../device/n1111111CNTRL"), "/devices"),
1606 "/api/v2/tailnet/..%2F..%2Fdevice%2Fn1111111CNTRL/devices"
1607 );
1608 }
1609
1610 #[test]
1611 fn a_client_that_could_not_work_is_refused_at_the_start() {
1612 for (what, adjust) in [
1613 (
1614 "no calls in flight",
1615 Box::new(|c: &mut ClientConfig| c.concurrency = 0) as Box<dyn FnOnce(&mut _)>,
1616 ),
1617 (
1618 "no bytes allowed back",
1619 Box::new(|c: &mut ClientConfig| c.max_response_bytes = 0),
1620 ),
1621 ] {
1622 let mut config = ClientConfig::new(api_key());
1623 adjust(&mut config);
1624 assert!(
1625 matches!(Client::new(config), Err(ApiError::Config(_))),
1626 "{what} should have been refused"
1627 );
1628 }
1629 }
1630}