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 matches!(config.tailnet.as_str(), "." | "..") {
114 return Err(ApiError::Config(format!(
115 "`{}` is not a tailnet name; as a path segment it would leave the \
116 tailnet's own path",
117 config.tailnet
118 )));
119 }
120 if config.concurrency == 0 {
121 return Err(ApiError::Config(
122 "at least one call has to be allowed in flight".to_owned(),
123 ));
124 }
125 if config.max_response_bytes == 0 {
126 return Err(ApiError::Config(
127 "a response size cap of zero would reject every answer".to_owned(),
128 ));
129 }
130
131 let http = reqwest::Client::builder()
132 .user_agent(config.user_agent)
133 .timeout(config.budget)
137 .build()
138 .map_err(|source| {
139 ApiError::Config(format!("the HTTP client could not be built: {source}"))
140 })?;
141
142 Ok(Self {
143 inner: Arc::new(Inner {
144 tokens: Tokens::new(config.credentials, &base_url, http.clone()),
145 http,
146 base_url,
147 tailnet: config.tailnet,
148 budget: config.budget,
149 max_response_bytes: config.max_response_bytes,
150 in_flight: Semaphore::new(config.concurrency),
151 }),
152 })
153 }
154
155 pub fn tailnet(&self) -> &str {
157 &self.inner.tailnet
158 }
159
160 pub fn tailnet_path(&self, tailnet: Option<&str>, rest: &str) -> String {
165 let tailnet = tailnet.map_or(self.tailnet(), str::trim);
166 let tailnet = if tailnet.is_empty() {
167 self.tailnet()
168 } else {
169 tailnet
170 };
171 format!("/api/v2/tailnet/{}{rest}", escape(tailnet))
172 }
173
174 pub fn get(&self, path: impl Into<String>) -> RequestBuilder<'_> {
175 self.request(reqwest::Method::GET, path)
176 }
177
178 pub fn post(&self, path: impl Into<String>) -> RequestBuilder<'_> {
179 self.request(reqwest::Method::POST, path)
180 }
181
182 pub fn put(&self, path: impl Into<String>) -> RequestBuilder<'_> {
183 self.request(reqwest::Method::PUT, path)
184 }
185
186 pub fn patch(&self, path: impl Into<String>) -> RequestBuilder<'_> {
187 self.request(reqwest::Method::PATCH, path)
188 }
189
190 pub fn delete(&self, path: impl Into<String>) -> RequestBuilder<'_> {
191 self.request(reqwest::Method::DELETE, path)
192 }
193
194 fn request(&self, method: reqwest::Method, path: impl Into<String>) -> RequestBuilder<'_> {
195 RequestBuilder {
196 client: self,
197 method,
198 path: path.into(),
199 query: Vec::new(),
200 headers: Vec::new(),
201 body: None,
202 broken: None,
203 }
204 }
205}
206
207#[derive(Debug)]
209pub struct RequestBuilder<'a> {
210 client: &'a Client,
211 method: reqwest::Method,
212 path: String,
213 query: Vec<(String, String)>,
214 headers: Vec<(String, String)>,
215 body: Option<Body>,
216 broken: Option<ApiError>,
219}
220
221#[derive(Debug, Clone)]
226enum Body {
227 Json(Value),
228 Text { content_type: String, text: String },
229}
230
231impl RequestBuilder<'_> {
232 #[must_use]
235 pub fn query(mut self, name: &str, value: impl std::fmt::Display) -> Self {
236 self.query.push((name.to_owned(), value.to_string()));
237 self
238 }
239
240 #[must_use]
242 pub fn maybe_query(self, name: &str, value: Option<impl std::fmt::Display>) -> Self {
243 match value {
244 Some(value) => self.query(name, value),
245 None => self,
246 }
247 }
248
249 #[must_use]
251 pub fn header(mut self, name: &str, value: impl Into<String>) -> Self {
252 self.headers.push((name.to_owned(), value.into()));
253 self
254 }
255
256 #[must_use]
258 pub fn json(mut self, body: &impl Serialize) -> Self {
259 match serde_json::to_value(body) {
260 Ok(value) => self.body = Some(Body::Json(value)),
261 Err(source) => {
262 self.broken.get_or_insert(ApiError::Config(format!(
263 "the request body could not be built: {source}"
264 )));
265 }
266 }
267 self
268 }
269
270 #[must_use]
277 pub fn text(mut self, content_type: &str, body: impl Into<String>) -> Self {
278 self.body = Some(Body::Text {
279 content_type: content_type.to_owned(),
280 text: body.into(),
281 });
282 self
283 }
284
285 pub async fn send(self) -> Result<Value, ApiError> {
290 let request = self.describe_request();
291 let answer = self.send_raw().await?;
292 parse(&answer.bytes, &request)
293 }
294
295 pub async fn send_as<T: DeserializeOwned>(self) -> Result<T, ApiError> {
297 Ok(self.send_answer().await?.value)
298 }
299
300 pub async fn send_answer<T: DeserializeOwned>(self) -> Result<Answer<T>, ApiError> {
319 let request = self.describe_request();
320 let answer = self.send_raw().await?;
321 let raw = parse(&answer.bytes, &request)?;
322 let value =
323 T::deserialize(&raw).map_err(|source| ApiError::Malformed { request, source })?;
324 Ok(Answer {
325 value,
326 raw,
327 etag: answer.etag,
328 })
329 }
330
331 pub async fn send_text(self) -> Result<TextBody, ApiError> {
336 let answer = self.send_raw().await?;
337 Ok(TextBody {
338 text: String::from_utf8_lossy(&answer.bytes).into_owned(),
342 etag: answer.etag,
343 })
344 }
345
346 fn describe_request(&self) -> String {
348 format!("{} {}", self.method, self.path)
349 }
350
351 async fn send_raw(self) -> Result<RawBody, ApiError> {
359 let request = self.describe_request();
360 let budget = self.client.inner.budget;
361 match tokio::time::timeout(budget, self.attempts()).await {
362 Ok(answer) => answer,
363 Err(_) => Err(ApiError::Timeout { request, budget }),
364 }
365 }
366
367 async fn attempts(self) -> Result<RawBody, ApiError> {
369 if let Some(broken) = self.broken {
370 return Err(broken);
371 }
372 let request = self.describe_request();
373 let idempotence = idempotence(&self.method);
374 let deadline = Instant::now() + self.client.inner.budget;
375 let inner = &self.client.inner;
376 let url = format!("{}{}", inner.base_url, self.path);
377
378 let mut attempt = 0;
379 let mut refreshed = false;
380 loop {
381 attempt += 1;
382 let outcome = self.attempt(&url, &request).await;
383 let error = match outcome {
384 Ok(answer) => return Ok(answer),
385 Err(error) => error,
386 };
387
388 if error.status() == Some(UNAUTHORIZED)
394 && inner.tokens.can_refresh()
395 && !refreshed
396 && attempt < MAX_ATTEMPTS
397 {
398 refreshed = true;
399 tracing::debug!(request = %request, "the token was refused; minting another");
400 continue;
401 }
402
403 let repeatable =
409 idempotence == Idempotence::Repeatable || error.status() == Some(TOO_MANY_REQUESTS);
410 if !error.is_transient() || !repeatable || attempt >= MAX_ATTEMPTS {
411 return Err(error);
412 }
413
414 let delay = backoff(attempt, &error);
415 if Instant::now() + delay >= deadline {
416 return Err(error);
419 }
420 tracing::debug!(
421 request = %request,
422 attempt,
423 delay_ms = delay.as_millis(),
424 because = %error,
425 "retrying a control-plane call"
426 );
427 tokio::time::sleep(delay).await;
428 }
429 }
430
431 async fn attempt(&self, url: &str, request: &str) -> Result<RawBody, ApiError> {
434 let inner = &self.client.inner;
435 let _permit = inner
436 .in_flight
437 .acquire()
438 .await
439 .map_err(|_| ApiError::Config("the client has been shut down".to_owned()))?;
440
441 let bearer = inner.tokens.bearer().await?;
442 let mut sending = inner
443 .http
444 .request(self.method.clone(), url)
445 .bearer_auth(bearer.value.expose())
446 .query(&self.query);
447 for (name, value) in &self.headers {
448 sending = sending.header(name, value);
449 }
450 match &self.body {
451 Some(Body::Json(value)) => sending = sending.json(value),
452 Some(Body::Text { content_type, text }) => {
453 sending = sending
454 .header(reqwest::header::CONTENT_TYPE, content_type)
455 .body(text.clone());
456 }
457 None => {}
458 }
459
460 let response = sending.send().await.map_err(|source| {
461 if source.is_timeout() {
462 ApiError::Timeout {
463 request: request.to_owned(),
464 budget: self.client.inner.budget,
465 }
466 } else {
467 ApiError::Transport {
468 request: request.to_owned(),
469 source,
470 }
471 }
472 })?;
473
474 let status = response.status();
475 if status.is_success() {
476 return read_body(response, request, inner.max_response_bytes).await;
477 }
478
479 if status.as_u16() == UNAUTHORIZED
480 && let Some(generation) = bearer.generation
481 {
482 inner.tokens.evict(generation).await;
486 }
487
488 let retry_after = retry_after(&response);
489 let body = read_body(response, request, MAX_ERROR_BYTES)
490 .await
491 .map(|raw| String::from_utf8_lossy(&raw.bytes).into_owned())
492 .unwrap_or_default();
493 Err(ApiError::Status {
494 request: request.to_owned(),
495 status: status.as_u16(),
496 message: describe(status, &body),
497 retry_after,
498 })
499 }
500}
501
502#[derive(Debug)]
504struct RawBody {
505 bytes: Vec<u8>,
506 etag: Option<String>,
507}
508
509fn parse(bytes: &[u8], request: &str) -> Result<Value, ApiError> {
514 if bytes.iter().all(u8::is_ascii_whitespace) {
515 return Ok(Value::Null);
516 }
517 serde_json::from_slice(bytes).map_err(|source| ApiError::Malformed {
518 request: request.to_owned(),
519 source,
520 })
521}
522
523#[derive(Debug, Clone)]
530pub struct Answer<T> {
531 pub value: T,
532 pub raw: Value,
534 pub etag: Option<String>,
536}
537
538#[derive(Debug, Clone)]
540pub struct TextBody {
541 pub text: String,
542 pub etag: Option<String>,
544}
545
546async fn read_body(
554 mut response: reqwest::Response,
555 request: &str,
556 cap: usize,
557) -> Result<RawBody, ApiError> {
558 let etag = response
559 .headers()
560 .get(reqwest::header::ETAG)
561 .and_then(|value| value.to_str().ok())
562 .map(str::to_owned);
563
564 let too_large = || ApiError::TooLarge {
565 request: request.to_owned(),
566 cap,
567 };
568 if response
569 .content_length()
570 .is_some_and(|len| len > cap as u64)
571 {
572 return Err(too_large());
573 }
574
575 let mut bytes = Vec::new();
576 while let Some(chunk) = response
577 .chunk()
578 .await
579 .map_err(|source| ApiError::Transport {
580 request: request.to_owned(),
581 source,
582 })?
583 {
584 if bytes.len() + chunk.len() > cap {
585 return Err(too_large());
586 }
587 bytes.extend_from_slice(&chunk);
588 }
589 Ok(RawBody { bytes, etag })
590}
591
592fn idempotence(method: &reqwest::Method) -> Idempotence {
598 match *method {
599 reqwest::Method::POST | reqwest::Method::PATCH => Idempotence::Once,
600 _ => Idempotence::Repeatable,
601 }
602}
603
604fn backoff(attempt: u32, error: &ApiError) -> Duration {
611 if let ApiError::Status {
612 retry_after: Some(asked),
613 ..
614 } = error
615 {
616 return (*asked).min(MAX_BACKOFF);
617 }
618 (BASE_BACKOFF * 2u32.saturating_pow(attempt - 1)).min(MAX_BACKOFF)
619}
620
621fn retry_after(response: &reqwest::Response) -> Option<Duration> {
626 response
627 .headers()
628 .get(reqwest::header::RETRY_AFTER)?
629 .to_str()
630 .ok()?
631 .trim()
632 .parse::<u64>()
633 .ok()
634 .map(Duration::from_secs)
635}
636
637pub fn checked_base_url(base_url: &str) -> Result<String, ApiError> {
651 let trimmed = base_url.trim().trim_end_matches('/');
652 let parsed = reqwest::Url::parse(trimmed)
653 .map_err(|source| ApiError::Config(format!("`{base_url}` is not a URL: {source}")))?;
654
655 let loopback = parsed.host_str().is_some_and(|host| {
656 let host = host.trim_start_matches('[').trim_end_matches(']');
659 host.eq_ignore_ascii_case("localhost")
660 || host
661 .parse::<std::net::IpAddr>()
662 .is_ok_and(|address| address.is_loopback())
663 });
664 if parsed.scheme() != "https" && !loopback {
665 return Err(ApiError::Config(format!(
666 "`{base_url}` is neither https nor a loopback address, and a \
667 control-plane credential is not sent anywhere else"
668 )));
669 }
670 if (!parsed.path().is_empty() && parsed.path() != "/")
671 || parsed.query().is_some()
672 || parsed.fragment().is_some()
673 {
674 return Err(ApiError::Config(format!(
675 "`{base_url}` has a path, query or fragment; the base URL is a host and nothing more"
676 )));
677 }
678 if !parsed.username().is_empty() || parsed.password().is_some() {
682 return Err(ApiError::Config(
683 "the base URL carries a username or password; a control-plane \
684 credential is sent as a header and never in a URL"
685 .to_owned(),
686 ));
687 }
688 Ok(trimmed.to_owned())
689}
690
691fn escape(segment: &str) -> String {
698 let mut out = String::with_capacity(segment.len());
699 for byte in segment.bytes() {
700 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~' | b'@') {
701 out.push(char::from(byte));
702 } else {
703 out.push_str(&format!("%{byte:02X}"));
704 }
705 }
706 out
707}
708
709#[cfg(test)]
710mod tests {
711 use std::collections::BTreeSet;
712 use std::path::PathBuf;
713
714 use serde_json::json;
715
716 use super::*;
717 use crate::fake::{FakeControlPlane, Response};
718 use crate::secret::Secret;
719
720 const KEY: &str = "tskey-api-redacted-example";
722 const DEVICES: &str = "/api/v2/tailnet/-/devices";
723
724 async fn fake() -> FakeControlPlane {
725 FakeControlPlane::start()
726 .await
727 .expect("a loopback socket is available")
728 }
729
730 fn client(fake: &FakeControlPlane, credentials: Credentials) -> Client {
731 client_with(fake, credentials, |_| {})
732 }
733
734 fn client_with(
735 fake: &FakeControlPlane,
736 credentials: Credentials,
737 adjust: impl FnOnce(&mut ClientConfig),
738 ) -> Client {
739 let mut config = ClientConfig::new(credentials);
740 config.base_url = fake.base_url().to_owned();
741 adjust(&mut config);
742 Client::new(config).expect("the fake answers on a loopback address")
743 }
744
745 fn api_key() -> Credentials {
746 Credentials::ApiKey(Secret::new(KEY))
747 }
748
749 fn oauth() -> Credentials {
750 Credentials::OauthClient {
751 client_id: "kExAmPlE1CNTRL".to_owned(),
752 client_secret: Secret::new("tskey-client-redacted-example"),
753 scopes: vec!["devices:read".to_owned(), "dns".to_owned()],
754 }
755 }
756
757 fn token(value: &str, seconds: u64) -> Response {
759 Response::json(json!({
760 "access_token": value,
761 "token_type": "Bearer",
762 "expires_in": seconds,
763 }))
764 }
765
766 fn bearers(fake: &FakeControlPlane) -> Vec<String> {
768 fake.recorded()
769 .into_iter()
770 .filter_map(|r| r.authorization().map(str::to_owned))
771 .collect()
772 }
773
774 #[tokio::test]
777 async fn an_api_key_is_the_bearer_token_itself() {
778 let fake = fake()
779 .await
780 .on("GET", DEVICES, Response::json(json!({"devices": []})));
781 let client = client(&fake, api_key());
782
783 let answer = client.get(DEVICES).send().await.expect("the fake answers");
784
785 assert_eq!(answer, json!({"devices": []}));
786 let request = fake.only_request();
788 assert_eq!(
789 request.authorization(),
790 Some(format!("Bearer {KEY}").as_str())
791 );
792 }
793
794 #[tokio::test]
795 async fn an_oauth_client_is_exchanged_for_a_token() {
796 let fake = fake()
797 .await
798 .on("POST", crate::token::TOKEN_PATH, token("minted-1", 3600))
799 .on("GET", DEVICES, Response::json(json!({"devices": []})));
800 let client = client(&fake, oauth());
801
802 client.get(DEVICES).send().await.expect("the fake answers");
803
804 let recorded = fake.recorded();
805 assert_eq!(
806 recorded.len(),
807 2,
808 "an exchange and then the call: {recorded:#?}"
809 );
810 let exchange = &recorded[0];
811 assert_eq!(exchange.path, crate::token::TOKEN_PATH);
812 for expected in [
813 "grant_type=client_credentials",
814 "client_id=kExAmPlE1CNTRL",
815 "client_secret=tskey-client-redacted-example",
816 "scope=devices%3Aread+dns",
819 ] {
820 assert!(
821 exchange.body.contains(expected),
822 "the exchange did not send `{expected}`: {}",
823 exchange.body
824 );
825 }
826 assert_eq!(recorded[1].authorization(), Some("Bearer minted-1"));
827 }
828
829 #[tokio::test]
830 async fn a_federated_identity_signs_with_the_jwt_on_disk() {
831 let directory = tempfile::tempdir().expect("a temporary directory");
832 let jwt_file = directory.path().join("token");
833 std::fs::write(&jwt_file, "header.payload.signature\n").expect("the file is written");
834
835 let fake = fake()
836 .await
837 .on(
838 "POST",
839 crate::token::TOKEN_PATH,
840 token("minted-federated", 3600),
841 )
842 .on("GET", DEVICES, Response::json(json!({"devices": []})));
843 let client = client(
844 &fake,
845 Credentials::Federated {
846 client_id: Some("kExAmPlE1CNTRL".to_owned()),
847 jwt_file,
848 scopes: Vec::new(),
849 },
850 );
851
852 client.get(DEVICES).send().await.expect("the fake answers");
853
854 let exchange = &fake.recorded()[0];
855 assert!(
856 exchange
857 .body
858 .contains("client_assertion=header.payload.signature"),
859 "the JWT was not sent, or was sent with its trailing newline: {}",
860 exchange.body
861 );
862 assert!(
863 exchange.body.contains("client_assertion_type=urn%3Aietf"),
864 "the assertion type was not sent: {}",
865 exchange.body
866 );
867 }
868
869 #[tokio::test]
870 async fn a_missing_jwt_file_says_which_file_it_was() {
871 let fake = fake().await;
872 let client = client(
873 &fake,
874 Credentials::Federated {
875 client_id: None,
876 jwt_file: PathBuf::from("/nonexistent/identity/token"),
877 scopes: Vec::new(),
878 },
879 );
880
881 let error = client
882 .get(DEVICES)
883 .send()
884 .await
885 .expect_err("there is no file");
886
887 assert!(
888 matches!(&error, ApiError::JwtFile { path, .. } if path.ends_with("token")),
889 "unexpected error: {error:?}"
890 );
891 assert_eq!(fake.request_count(), 0, "nothing should have been sent");
892 }
893
894 #[tokio::test]
895 async fn the_credential_with_precedence_is_the_one_that_is_used() {
896 let environment = |key: &str| match key {
899 crate::credentials::API_KEY_ENV => Some(KEY.to_owned()),
900 crate::credentials::OAUTH_CLIENT_ID_ENV => Some("kExAmPlE1CNTRL".to_owned()),
901 crate::credentials::OAUTH_CLIENT_SECRET_ENV => Some("unused".to_owned()),
902 _ => None,
903 };
904 let credentials = Credentials::from_source(environment).expect("both are set");
905
906 let fake = fake().await.on("GET", DEVICES, Response::json(json!({})));
907 let client = client(&fake, credentials);
908 client.get(DEVICES).send().await.expect("the fake answers");
909
910 let request = fake.only_request();
911 assert_eq!(
912 request.authorization(),
913 Some(format!("Bearer {KEY}").as_str())
914 );
915 }
916
917 #[tokio::test]
920 async fn a_token_is_minted_once_and_reused() {
921 let fake = fake()
922 .await
923 .on("POST", crate::token::TOKEN_PATH, token("minted-1", 3600))
924 .on("GET", DEVICES, Response::json(json!({})));
925 let client = client(&fake, oauth());
926
927 for _ in 0..3 {
928 client.get(DEVICES).send().await.expect("the fake answers");
929 }
930
931 let exchanges = fake
932 .recorded()
933 .iter()
934 .filter(|r| r.path == crate::token::TOKEN_PATH)
935 .count();
936 assert_eq!(exchanges, 1, "the token should have been minted once");
937 assert_eq!(bearers(&fake), vec!["Bearer minted-1".to_owned(); 3]);
940 }
941
942 #[tokio::test]
943 async fn a_token_near_its_expiry_is_minted_again() {
944 let remaining = crate::token::REFRESH_SKEW.as_secs() / 2;
949 let fake = fake()
950 .await
951 .on(
952 "POST",
953 crate::token::TOKEN_PATH,
954 token("minted-1", remaining),
955 )
956 .on("GET", DEVICES, Response::json(json!({})));
957 let client = client(&fake, oauth());
958
959 for _ in 0..2 {
960 client.get(DEVICES).send().await.expect("the fake answers");
961 }
962
963 let exchanges = fake
964 .recorded()
965 .iter()
966 .filter(|r| r.path == crate::token::TOKEN_PATH)
967 .count();
968 assert_eq!(exchanges, 2, "a token inside the skew should not be reused");
969 }
970
971 #[tokio::test]
972 async fn a_refused_token_is_replaced_exactly_once() {
973 let fake = fake()
974 .await
975 .once("POST", crate::token::TOKEN_PATH, token("stale", 3600))
976 .on("POST", crate::token::TOKEN_PATH, token("fresh", 3600))
977 .once(
978 "GET",
979 DEVICES,
980 Response::status(401, json!({"message": "expired"})),
981 )
982 .on("GET", DEVICES, Response::json(json!({"devices": []})));
983 let client = client(&fake, oauth());
984
985 let answer = client
986 .get(DEVICES)
987 .send()
988 .await
989 .expect("the second try works");
990
991 assert_eq!(answer, json!({"devices": []}));
992 assert_eq!(
993 bearers(&fake),
994 vec!["Bearer stale".to_owned(), "Bearer fresh".to_owned()],
995 "the refused token should have been replaced, once"
996 );
997 }
998
999 #[tokio::test]
1000 async fn a_token_refused_twice_is_the_credential_being_wrong() {
1001 let fake = fake()
1002 .await
1003 .on("POST", crate::token::TOKEN_PATH, token("minted", 3600))
1004 .on(
1005 "GET",
1006 DEVICES,
1007 Response::status(401, json!({"message": "no"})),
1008 );
1009 let client = client(&fake, oauth());
1010
1011 let error = client
1012 .get(DEVICES)
1013 .send()
1014 .await
1015 .expect_err("it is always refused");
1016
1017 assert_eq!(error.status(), Some(401));
1018 let calls = fake.recorded().iter().filter(|r| r.path == DEVICES).count();
1019 assert_eq!(calls, 2, "one retry with a fresh token, and then no more");
1020 }
1021
1022 #[tokio::test]
1023 async fn a_refused_api_key_is_not_replaced_because_there_is_nothing_to_mint() {
1024 let fake = fake().await.on(
1025 "GET",
1026 DEVICES,
1027 Response::status(401, json!({"message": "no"})),
1028 );
1029 let client = client(&fake, api_key());
1030
1031 let error = client.get(DEVICES).send().await.expect_err("it is refused");
1032
1033 assert_eq!(error.status(), Some(401));
1034 assert_eq!(fake.request_count(), 1);
1035 }
1036
1037 #[tokio::test]
1040 async fn a_transient_failure_on_a_repeatable_method_is_retried() {
1041 let fake = fake()
1042 .await
1043 .once(
1044 "GET",
1045 DEVICES,
1046 Response::status(503, json!({"message": "later"})),
1047 )
1048 .on("GET", DEVICES, Response::json(json!({"devices": []})));
1049 let client = client(&fake, api_key());
1050
1051 let answer = client
1052 .get(DEVICES)
1053 .send()
1054 .await
1055 .expect("the second try works");
1056
1057 assert_eq!(answer, json!({"devices": []}));
1058 assert_eq!(fake.request_count(), 2);
1059 }
1060
1061 #[tokio::test]
1062 async fn a_transient_failure_on_an_unsafe_method_is_not_retried() {
1063 let keys = "/api/v2/tailnet/-/keys";
1066 let fake = fake().await.on(
1067 "POST",
1068 keys,
1069 Response::status(503, json!({"message": "later"})),
1070 );
1071 let client = client(&fake, api_key());
1072
1073 let error = client
1074 .post(keys)
1075 .json(&json!({"capabilities": {}}))
1076 .send()
1077 .await
1078 .expect_err("the fake never succeeds");
1079
1080 assert_eq!(error.status(), Some(503));
1081 assert_eq!(fake.request_count(), 1, "a POST must not be sent twice");
1082 }
1083
1084 #[tokio::test]
1085 async fn a_rate_limit_is_retried_even_on_an_unsafe_method() {
1086 let keys = "/api/v2/tailnet/-/keys";
1089 let fake = fake()
1090 .await
1091 .once(
1092 "POST",
1093 keys,
1094 Response::status(429, json!({"message": "slow down"}))
1095 .with_header("retry-after", "0"),
1096 )
1097 .on(
1098 "POST",
1099 keys,
1100 Response::json(json!({"key": "tskey-auth-redacted-example"})),
1101 );
1102 let client = client(&fake, api_key());
1103
1104 let answer = client
1105 .post(keys)
1106 .json(&json!({"capabilities": {}}))
1107 .send()
1108 .await
1109 .expect("the second try works");
1110
1111 assert_eq!(answer["key"], json!("tskey-auth-redacted-example"));
1112 assert_eq!(fake.request_count(), 2);
1113 }
1114
1115 #[tokio::test]
1116 async fn a_permanent_failure_is_not_retried() {
1117 let fake = fake().await.on(
1118 "GET",
1119 DEVICES,
1120 Response::status(404, json!({"message": "no such tailnet"})),
1121 );
1122 let client = client(&fake, api_key());
1123
1124 let error = client
1125 .get(DEVICES)
1126 .send()
1127 .await
1128 .expect_err("there is nothing there");
1129
1130 assert!(
1131 matches!(&error, ApiError::Status { message, .. } if message == "no such tailnet"),
1132 "the API's own message should be passed on: {error:?}"
1133 );
1134 assert_eq!(fake.request_count(), 1);
1135 }
1136
1137 #[tokio::test]
1138 async fn the_wait_a_server_asks_for_is_read_off_the_wire() {
1139 let budget = Duration::from_secs(1);
1149 let refusal = |wait| {
1150 Response::status(503, json!({"message": "later"})).with_header("retry-after", wait)
1151 };
1152
1153 let patient = fake().await.on("GET", DEVICES, refusal("300"));
1154 let error = client_with(&patient, api_key(), |c| c.budget = budget)
1155 .get(DEVICES)
1156 .send()
1157 .await
1158 .expect_err("it never works");
1159 assert_eq!(error.status(), Some(503), "not a bare timeout: {error:?}");
1160 assert_eq!(
1161 patient.request_count(),
1162 1,
1163 "the server asked for longer than the budget, so there was no second try; \
1164 ignoring the header would have waited {BASE_BACKOFF:?} and tried again"
1165 );
1166
1167 let impatient = fake().await.on("GET", DEVICES, refusal("0"));
1168 client_with(&impatient, api_key(), |c| c.budget = budget)
1169 .get(DEVICES)
1170 .send()
1171 .await
1172 .expect_err("it never works");
1173 assert_eq!(
1174 impatient.request_count(),
1175 MAX_ATTEMPTS as usize,
1176 "a server asking for no wait at all should be believed too"
1177 );
1178 }
1179
1180 #[test]
1181 fn the_server_is_believed_about_when_to_come_back() {
1182 let asked = |seconds| ApiError::Status {
1183 request: "GET /x".to_owned(),
1184 status: 429,
1185 message: String::new(),
1186 retry_after: Some(Duration::from_secs(seconds)),
1187 };
1188 let guessed = ApiError::Status {
1190 request: "GET /x".to_owned(),
1191 status: 503,
1192 message: String::new(),
1193 retry_after: None,
1194 };
1195
1196 assert_eq!(backoff(1, &asked(5)), Duration::from_secs(5));
1197 assert_eq!(backoff(1, &asked(600)), MAX_BACKOFF);
1200 assert_eq!(backoff(1, &guessed), BASE_BACKOFF);
1202 assert_eq!(backoff(2, &guessed), BASE_BACKOFF * 2);
1203 assert_eq!(backoff(30, &guessed), MAX_BACKOFF);
1204 }
1205
1206 #[test]
1207 fn only_the_methods_http_calls_idempotent_may_be_repeated() {
1208 for method in [
1209 reqwest::Method::GET,
1210 reqwest::Method::HEAD,
1211 reqwest::Method::PUT,
1212 reqwest::Method::DELETE,
1213 ] {
1214 assert_eq!(idempotence(&method), Idempotence::Repeatable, "{method}");
1215 }
1216 for method in [reqwest::Method::POST, reqwest::Method::PATCH] {
1217 assert_eq!(idempotence(&method), Idempotence::Once, "{method}");
1218 }
1219 }
1220
1221 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1224 async fn no_more_calls_are_in_flight_than_the_limit_allows() {
1225 const LIMIT: usize = 2;
1226 let fake = fake().await.on(
1227 "GET",
1228 DEVICES,
1229 Response::json(json!({})).slow(Duration::from_millis(80)),
1230 );
1231 let client = client_with(&fake, api_key(), |config| config.concurrency = LIMIT);
1232
1233 let calls: Vec<_> = (0..8)
1234 .map(|_| {
1235 let client = client.clone();
1236 tokio::spawn(async move { client.get(DEVICES).send().await })
1237 })
1238 .collect();
1239 for call in calls {
1240 call.await
1241 .expect("the task finished")
1242 .expect("the fake answers");
1243 }
1244
1245 assert_eq!(fake.request_count(), 8);
1246 let peak = fake.peak_concurrency();
1247 assert!(
1248 (1..=LIMIT).contains(&peak),
1249 "{peak} calls were in flight at once, and the limit is {LIMIT}"
1250 );
1251 }
1252
1253 #[tokio::test]
1256 async fn an_answer_over_the_cap_is_refused_rather_than_truncated() {
1257 let big = json!({"devices": vec![json!({"name": "x".repeat(200)})]});
1258 let fake = fake().await.on("GET", DEVICES, Response::json(&big));
1259 let client = client_with(&fake, api_key(), |config| config.max_response_bytes = 64);
1260
1261 let error = client
1262 .get(DEVICES)
1263 .send()
1264 .await
1265 .expect_err("it is too large");
1266
1267 assert!(
1268 matches!(error, ApiError::TooLarge { cap: 64, .. }),
1269 "a truncated body would have failed to parse instead: {error:?}"
1270 );
1271 }
1272
1273 #[tokio::test]
1274 async fn an_answer_with_no_stated_length_is_refused_while_it_is_read() {
1275 let big = json!({"devices": vec![json!({"name": "x".repeat(200)})]});
1278 let fake = fake()
1279 .await
1280 .on("GET", DEVICES, Response::json(&big).chunked());
1281 let client = client_with(&fake, api_key(), |config| config.max_response_bytes = 64);
1282
1283 let error = client
1284 .get(DEVICES)
1285 .send()
1286 .await
1287 .expect_err("it is too large");
1288
1289 assert!(
1290 matches!(error, ApiError::TooLarge { cap: 64, .. }),
1291 "unexpected error: {error:?}"
1292 );
1293 }
1294
1295 #[tokio::test]
1296 async fn an_answer_under_the_cap_arrives_whole_however_it_is_framed() {
1297 let body = json!({"devices": [{"name": "workstation"}]});
1298 let fake = fake()
1299 .await
1300 .on("GET", DEVICES, Response::json(&body).chunked());
1301 let client = client(&fake, api_key());
1302
1303 let answer = client.get(DEVICES).send().await.expect("the fake answers");
1304
1305 assert_eq!(answer, body);
1306 }
1307
1308 #[tokio::test]
1311 async fn an_empty_body_is_an_answer_rather_than_a_parse_failure() {
1312 let device = "/api/v2/device/n1111111CNTRL";
1313 let fake = fake().await.on("DELETE", device, Response::empty());
1314 let client = client(&fake, api_key());
1315
1316 let answer = client
1317 .delete(device)
1318 .send()
1319 .await
1320 .expect("the fake answers");
1321
1322 assert_eq!(answer, Value::Null, "a deletion answers with nothing");
1323 }
1324
1325 #[tokio::test]
1326 async fn an_answer_is_read_both_ways_from_one_parse() {
1327 let body = json!({
1332 "id": "kExAmPlE",
1333 "description": "a key",
1334 "invented": {"by": "a later control plane"},
1335 });
1336 let keys = "/api/v2/tailnet/-/keys/kExAmPlE";
1337 let fake = fake().await.on("GET", keys, Response::json(&body));
1338 let client = client(&fake, api_key());
1339
1340 let answer = client
1341 .get(keys)
1342 .send_answer::<crate::models::key::Key>()
1343 .await
1344 .expect("the fake answers");
1345
1346 assert_eq!(answer.value.id.as_deref(), Some("kExAmPlE"));
1347 assert_eq!(
1348 answer.value.unknown.get("invented"),
1349 Some(&json!({"by": "a later control plane"})),
1350 "the typed half keeps what it had no field for"
1351 );
1352 assert_eq!(answer.raw, body, "and the raw half is the body, untouched");
1353 }
1354
1355 #[tokio::test]
1356 async fn an_answer_carries_the_etag_that_versions_it() {
1357 let acl = "/api/v2/tailnet/-/acl";
1360 let fake = fake().await.on(
1361 "GET",
1362 acl,
1363 Response::json(json!({"acls": []})).with_header("ETag", "\"abc123\""),
1364 );
1365 let client = client(&fake, api_key());
1366
1367 let answer = client
1368 .get(acl)
1369 .send_answer::<Value>()
1370 .await
1371 .expect("the fake answers");
1372
1373 assert_eq!(answer.etag.as_deref(), Some("\"abc123\""));
1374 }
1375
1376 #[tokio::test]
1377 async fn the_query_and_the_body_reach_the_control_plane_as_written() {
1378 let fake = fake().await.on("POST", DEVICES, Response::json(json!({})));
1379 let client = client(&fake, api_key());
1380
1381 client
1382 .post(DEVICES)
1383 .query("fields", "all")
1384 .maybe_query("since", Some(7))
1385 .maybe_query("until", Option::<u8>::None)
1386 .header("If-Match", "\"v1\"")
1387 .json(&json!({"name": "workstation"}))
1388 .send()
1389 .await
1390 .expect("the fake answers");
1391
1392 let request = fake.only_request();
1393 assert_eq!(
1394 request
1395 .query
1396 .keys()
1397 .map(String::as_str)
1398 .collect::<BTreeSet<_>>(),
1399 BTreeSet::from(["fields", "since"]),
1400 "an absent parameter should not be sent"
1401 );
1402 assert_eq!(request.query["fields"], "all");
1403 assert_eq!(
1404 request.headers.get("if-match").map(String::as_str),
1405 Some("\"v1\"")
1406 );
1407 assert_eq!(request.json(), json!({"name": "workstation"}));
1408 }
1409
1410 #[tokio::test]
1411 async fn text_comes_back_with_the_version_it_was_read_at() {
1412 let policy = "/api/v2/tailnet/-/acl";
1415 let hujson = "{\n // a comment, which JSON does not have\n \"acls\": [],\n}";
1416 let fake = fake().await.on(
1417 "GET",
1418 policy,
1419 Response {
1420 status: 200,
1421 headers: vec![("content-type".to_owned(), "application/hujson".to_owned())],
1422 body: hujson.to_owned(),
1423 delay: Duration::ZERO,
1424 chunked: false,
1425 }
1426 .with_header("etag", "\"abc123\""),
1427 );
1428 let client = client(&fake, api_key());
1429
1430 let answer = client
1431 .get(policy)
1432 .send_text()
1433 .await
1434 .expect("the fake answers");
1435
1436 assert_eq!(answer.text, hujson);
1437 assert_eq!(answer.etag.as_deref(), Some("\"abc123\""));
1438 }
1439
1440 #[tokio::test]
1441 async fn a_body_that_is_not_what_was_asked_for_says_so() {
1442 #[derive(Debug, serde::Deserialize)]
1443 struct Devices {
1444 #[allow(dead_code)]
1445 devices: Vec<String>,
1446 }
1447 let fake = fake()
1448 .await
1449 .on("GET", DEVICES, Response::json(json!({"devices": 7})));
1450 let client = client(&fake, api_key());
1451
1452 let error = client
1453 .get(DEVICES)
1454 .send_as::<Devices>()
1455 .await
1456 .expect_err("seven is not a list");
1457
1458 assert!(
1459 matches!(&error, ApiError::Malformed { request, .. } if request == "GET /api/v2/tailnet/-/devices"),
1460 "unexpected error: {error:?}"
1461 );
1462 }
1463
1464 #[test]
1467 fn a_base_url_is_an_encrypted_host_and_nothing_more() {
1468 for allowed in [
1473 DEFAULT_BASE_URL,
1474 "https://api.example.com",
1475 "https://example.com",
1476 "http://127.0.0.1:8080",
1477 "http://localhost:9999",
1478 "http://[::1]:1234",
1479 ] {
1480 assert!(
1481 checked_base_url(allowed).is_ok(),
1482 "{allowed} should have been accepted"
1483 );
1484 }
1485 for refused in [
1486 "http://api.tailscale.com",
1489 "http://evil.example.com",
1490 "api.tailscale.com",
1492 "ftp://api.tailscale.com",
1493 "https://api.tailscale.com/api/v2",
1496 "https://api.tailscale.com/?tailnet=other",
1498 "https://api.tailscale.com#fragment",
1499 "https://user:pass@example.com",
1504 "https://token@example.com",
1505 ] {
1506 assert!(
1507 checked_base_url(refused).is_err(),
1508 "{refused} should have been refused"
1509 );
1510 }
1511 assert_eq!(
1514 checked_base_url("https://api.tailscale.com/").expect("a valid URL"),
1515 DEFAULT_BASE_URL
1516 );
1517 }
1518
1519 #[test]
1520 fn a_tailnet_named_as_a_dot_segment_is_refused() {
1521 for tailnet in [".", ".."] {
1522 let mut config = ClientConfig::new(api_key());
1523 config.tailnet = tailnet.to_owned();
1524 assert!(
1525 Client::new(config).is_err(),
1526 "`{tailnet}` should have been refused"
1527 );
1528 }
1529 }
1530
1531 #[test]
1532 fn a_name_in_a_path_cannot_reach_into_the_path_around_it() {
1533 let fake_config = ClientConfig::new(api_key());
1534 let client = Client::new(fake_config).expect("the default base URL is valid");
1535
1536 assert_eq!(
1537 client.tailnet_path(None, "/devices"),
1538 "/api/v2/tailnet/-/devices"
1539 );
1540 assert_eq!(
1541 client.tailnet_path(Some("example.com"), "/dns/nameservers"),
1542 "/api/v2/tailnet/example.com/dns/nameservers"
1543 );
1544 assert_eq!(
1546 client.tailnet_path(Some(" "), "/devices"),
1547 "/api/v2/tailnet/-/devices"
1548 );
1549 assert_eq!(
1551 client.tailnet_path(Some("../../device/n1111111CNTRL"), "/devices"),
1552 "/api/v2/tailnet/..%2F..%2Fdevice%2Fn1111111CNTRL/devices"
1553 );
1554 }
1555
1556 #[test]
1557 fn a_client_that_could_not_work_is_refused_at_the_start() {
1558 for (what, adjust) in [
1559 (
1560 "no calls in flight",
1561 Box::new(|c: &mut ClientConfig| c.concurrency = 0) as Box<dyn FnOnce(&mut _)>,
1562 ),
1563 (
1564 "no bytes allowed back",
1565 Box::new(|c: &mut ClientConfig| c.max_response_bytes = 0),
1566 ),
1567 ] {
1568 let mut config = ClientConfig::new(api_key());
1569 adjust(&mut config);
1570 assert!(
1571 matches!(Client::new(config), Err(ApiError::Config(_))),
1572 "{what} should have been refused"
1573 );
1574 }
1575 }
1576}