Skip to main content

tailscale_rest/
client.rs

1//! The transport: one client, and the rules every call goes through.
2//!
3//! Everything above this module builds a path and reads a body. What lives
4//! here is the part that is the same for all 93 tailnet tools — which
5//! credential goes on the wire, when a failed call is worth repeating, how many
6//! may be in flight at once, and how large an answer this server will hold.
7
8use 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
20/// The control plane. Pinned rather than configured, because a server that can
21/// be pointed at another host is a server that can be pointed at an attacker's.
22pub const DEFAULT_BASE_URL: &str = "https://api.tailscale.com";
23
24/// How long one call may take, across every attempt it makes.
25pub const DEFAULT_BUDGET: Duration = Duration::from_secs(30);
26
27/// How many calls may be in flight at once.
28///
29/// The control plane rate-limits per tailnet, and an agent that fans out over
30/// a device list can produce a hundred calls from one thought. Holding the
31/// fan-in here turns that into a queue rather than a wall of 429s.
32pub const DEFAULT_CONCURRENCY: usize = 8;
33
34/// The two statuses this module decides something about, named so that the
35/// decision and the check are spelled the same way wherever they appear.
36const UNAUTHORIZED: u16 = 401;
37const TOO_MANY_REQUESTS: u16 = 429;
38
39/// How many times one call is attempted, first try included.
40const MAX_ATTEMPTS: u32 = 4;
41
42/// The first backoff, doubled per retry.
43const BASE_BACKOFF: Duration = Duration::from_millis(250);
44
45/// The longest wait honoured from a `Retry-After`.
46///
47/// A server asking for ten minutes is asking for longer than any call budget,
48/// and sleeping on it would only spend the budget doing nothing.
49const MAX_BACKOFF: Duration = Duration::from_secs(20);
50
51/// How much of a failed call's body is read before it is described.
52///
53/// The size cap is about results; an error message is not a result, and reading
54/// a megabyte of one to print a sentence helps nobody.
55const MAX_ERROR_BYTES: usize = 8 * 1024;
56
57/// How to reach the control plane.
58#[derive(Debug, Clone)]
59pub struct ClientConfig {
60    /// Where to send. [`DEFAULT_BASE_URL`] unless a test says otherwise.
61    pub base_url: String,
62    /// The tailnet a path means when the caller does not name one.
63    pub tailnet: String,
64    pub credentials: Credentials,
65    /// The whole of one call, retries and backoff included.
66    pub budget: Duration,
67    pub concurrency: usize,
68    /// The largest body this server will hold in memory.
69    pub max_response_bytes: usize,
70    pub user_agent: String,
71}
72
73impl ClientConfig {
74    /// A configuration pointed at the real control plane.
75    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/// A client for the control plane.
89///
90/// Cloning is cheap and shares everything: the connection pool, the token, and
91/// — the point of sharing it — the concurrency limit. Two clients would be two
92/// limits, which is one limit too many.
93#[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        // Escaping cannot help: a URL parser reads `%2E%2E` as `..` too.
113        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            // Not the call budget: that covers every attempt together and is
134            // applied around the retry loop. This is the ceiling on one of
135            // them, so a stalled connection cannot eat a whole budget alone.
136            .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    /// The tailnet a call means when the caller does not name one.
156    pub fn tailnet(&self) -> &str {
157        &self.inner.tailnet
158    }
159
160    /// The path of a tailnet-scoped resource, for `tailnet` or the default.
161    ///
162    /// Every tailnet path is built here so that a tailnet name carrying a
163    /// slash cannot reach into a path it was not given.
164    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/// One call, before it is sent.
208#[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    /// A failure that happened while the call was being built, kept until
217    /// there is somewhere to return it from.
218    broken: Option<ApiError>,
219}
220
221/// What a request carries, and how it is spelled on the wire.
222///
223/// Two shapes rather than one, because the policy endpoints take a document
224/// this server did not author and must not reformat.
225#[derive(Debug, Clone)]
226enum Body {
227    Json(Value),
228    Text { content_type: String, text: String },
229}
230
231impl RequestBuilder<'_> {
232    /// A query parameter. Repeating a name sends it twice, which is how the
233    /// API spells a list.
234    #[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    /// A query parameter, if there is one to send.
241    #[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    /// A header. `If-Match` on the policy file is what this is for.
250    #[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    /// A JSON body.
257    #[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    /// A body that is text rather than JSON, under a content type of its own.
271    ///
272    /// The policy file is the reason: it is HuJSON — JSON with comments and
273    /// trailing commas — and a caller who wrote one wants it sent as written,
274    /// comments included. Sending it as a JSON string would send the document
275    /// quoted and escaped, which is a different document.
276    #[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    /// Send it, and read the answer as JSON.
286    ///
287    /// An empty body — which is what a successful `DELETE` sends — comes back
288    /// as [`Value::Null`] rather than as a failure to parse nothing.
289    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    /// Send it, and read the answer as a particular shape.
296    pub async fn send_as<T: DeserializeOwned>(self) -> Result<T, ApiError> {
297        Ok(self.send_answer().await?.value)
298    }
299
300    /// Send it, and read the answer both ways at once.
301    ///
302    /// ADR-0003 asks for "the parsed model together with the raw body and the
303    /// headers that matter", and this is why: a tool forwards the body it was
304    /// given, unrenamed and with every field the control plane sent, while the
305    /// server reads the typed value to decide what to do next. Parsing twice
306    /// would be two chances to disagree, so the model is deserialised from the
307    /// [`Value`] rather than from the bytes a second time.
308    ///
309    /// An empty body reads as [`Value::Null`], the same as [`send`] gives it,
310    /// and `T` has to be a type that can read null — [`Value`] or `()` or an
311    /// [`Option`]. A model cannot: every one of them carries a flattened map
312    /// of unknown fields, which makes it a map to serde, and serde will not
313    /// read a map from null. That is the right way round, because the
314    /// endpoints that answer with nothing are the deletions, and a deletion
315    /// has no model to answer with.
316    ///
317    /// [`send`]: RequestBuilder::send
318    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    /// Send it, and read the answer as text.
332    ///
333    /// The policy file is HuJSON — comments and trailing commas — so it is not
334    /// JSON to parse, and its `ETag` is what a later write has to quote.
335    pub async fn send_text(self) -> Result<TextBody, ApiError> {
336        let answer = self.send_raw().await?;
337        Ok(TextBody {
338            // The API sends UTF-8; anything else is a corrupted body, and
339            // replacing the bad bytes says so more usefully than a parse
340            // error about an offset nobody can see.
341            text: String::from_utf8_lossy(&answer.bytes).into_owned(),
342            etag: answer.etag,
343        })
344    }
345
346    /// `GET /api/v2/tailnet/-/devices`, for saying which call this was.
347    fn describe_request(&self) -> String {
348        format!("{} {}", self.method, self.path)
349    }
350
351    /// Every attempt this call is allowed, and whatever the last one produced.
352    ///
353    /// The budget bounds the whole of it. The retry loop stops short of a
354    /// sleep that would run past the deadline, which is the tidy way out and
355    /// leaves the caller holding the failure that caused the wait; the timeout
356    /// around the loop is the untidy one, for an attempt that is still going
357    /// when the budget is already spent.
358    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    /// The retry loop itself, bounded from outside by [`Self::send_raw`].
368    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            // A refused token is its own kind of retry, and a short one: the
389            // attempt evicted it, so going round again mints another and sends
390            // that. Once per call, because a second 401 on a fresh token is
391            // the credential being wrong rather than the token being stale,
392            // and no method is at risk — a 401 means nothing was done.
393            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            // Two conditions, and both have to hold. `is_transient` is about
404            // the failure — would asking again plausibly work — and this is
405            // about the request: a `POST` that may have been acted on before
406            // the answer went missing must not be sent twice. A 429 is the
407            // exception, and only because it means the server declined to act.
408            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                // Sleeping past the budget would turn a described failure into
417                // a bare timeout. The caller is better told what went wrong.
418                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    /// One attempt: a permit, a token, a request, and an answer read under the
432    /// size cap.
433    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            // The token was refused, so the next attempt must not send it
483            // again. Doing this by generation is what keeps one rejection from
484            // throwing away several freshly minted tokens in a row.
485            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/// A body, read whole.
503#[derive(Debug)]
504struct RawBody {
505    bytes: Vec<u8>,
506    etag: Option<String>,
507}
508
509/// A body, parsed. Empty means nothing was said, not that nothing parsed.
510///
511/// One function rather than three copies of the same two lines, so that the
512/// three `send` methods cannot come to disagree about what an empty body is.
513fn 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/// A parsed answer, and the body it was parsed from.
524///
525/// Both halves are kept because both are used: `raw` is what a tool hands
526/// back, so the caller sees whatever the control plane sent rather than the
527/// subset this build knows the names of, and `value` is what the server reads
528/// when it has to act on the answer.
529#[derive(Debug, Clone)]
530pub struct Answer<T> {
531    pub value: T,
532    /// The body, parsed as JSON and otherwise untouched.
533    pub raw: Value,
534    /// The `ETag` header, for the endpoints that version their document.
535    pub etag: Option<String>,
536}
537
538/// A body that was asked for as text, and the version it was read at.
539#[derive(Debug, Clone)]
540pub struct TextBody {
541    pub text: String,
542    /// The `ETag` header, which the policy file's writes quote back.
543    pub etag: Option<String>,
544}
545
546/// Read a body, refusing rather than truncating one that is too large.
547///
548/// Truncating would be worse than failing: half a JSON document does not parse,
549/// and half a device list that does parse is a wrong answer nobody can see is
550/// wrong. The cap is checked against `Content-Length` first so an enormous
551/// answer is refused before it is transferred, and again while reading, because
552/// a chunked response does not have one.
553async 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
592/// HTTP's own answer to whether a request may be sent twice.
593///
594/// `GET`, `HEAD`, `PUT` and `DELETE` are defined to be idempotent and `POST`
595/// and `PATCH` are not, so this needs no table of its own: minting an auth key
596/// is a `POST` because minting twice is two keys.
597fn idempotence(method: &reqwest::Method) -> Idempotence {
598    match *method {
599        reqwest::Method::POST | reqwest::Method::PATCH => Idempotence::Once,
600        _ => Idempotence::Repeatable,
601    }
602}
603
604/// How long to wait before attempt number `attempt + 1`.
605///
606/// The server's own `Retry-After` wins where it sent one: it knows when its
607/// limit resets and this side is guessing. Everything else doubles from
608/// [`BASE_BACKOFF`]. There is no jitter, because there is one client here and
609/// nobody to collide with.
610fn 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
621/// The `Retry-After` header, in the seconds form the API sends.
622///
623/// The HTTP-date form is legal and Tailscale does not use it; reading a wrong
624/// number out of a date would be worse than falling back to the backoff.
625fn 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
637/// Where a client may point.
638///
639/// A base URL is where every credential this server holds gets sent, so the
640/// three things asked of one are the three that keep it from being a way to
641/// send them somewhere in the clear: the transport is `https`, or the host is
642/// this machine, which is how the fake in this crate is reached; there is no
643/// path, query or fragment, because a base URL is a host and nothing more;
644/// and there is no userinfo, because a credential in a URL is a credential
645/// that gets printed.
646///
647/// What this does not do is name the host. `https://api.tailscale.com` is the
648/// default, and the guarantee here is about how a credential travels rather
649/// than about where it lands.
650pub 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        // `host_str` keeps an IPv6 address in the brackets the URL form
657        // requires, and `IpAddr` does not parse those.
658        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    // The URL is deliberately not echoed back here: the objection to userinfo
679    // is that it is a secret in a place secrets get printed, and this message
680    // is printed.
681    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
691/// One path segment, with everything that is not plainly safe escaped.
692///
693/// A tailnet is named by a domain and a device by an opaque identifier, so in
694/// practice nothing here needs escaping. The point is the case where something
695/// does: a `/` in a name would otherwise be a segment boundary, and a name is
696/// not always chosen by the person the path is built for.
697fn 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    /// A key that is obviously not one.
721    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    /// A token endpoint answer worth `seconds`.
758    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    /// Every `Authorization` header that arrived, in order.
767    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    // ---- authentication -------------------------------------------------
775
776    #[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        // One request, so nothing was exchanged: a key is already a token.
787        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            // OAuth spells a scope list space-separated whatever separator the
817            // environment variable used.
818            "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        // Both are set, which is what an operator with an old key in a shell
897        // profile looks like. The key wins, so no exchange happens at all.
898        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    // ---- the token's life ------------------------------------------------
918
919    #[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        // The exchange itself carries no bearer — it is how one is obtained —
938        // so the three calls are the three headers.
939        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        // Half a minute of life left, which is a token the clock still calls
945        // valid: only the refresh skew makes this one spent. A token that had
946        // already expired would be re-minted with no skew at all, and so would
947        // prove nothing about the last minute this deliberately gives up.
948        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    // ---- retry -----------------------------------------------------------
1038
1039    #[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        // The point of the rule: this is the shape of minting an auth key,
1064        // where a retry is a second key nobody asked for and nobody sees.
1065        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        // A 429 says the server declined to act, so nothing happened and the
1087        // reason not to repeat a POST does not apply.
1088        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        // `the_server_is_believed_about_when_to_come_back` builds the header's
1140        // value by hand and so proves only what `backoff` does with it. This
1141        // is the other half: the same status and the same budget, differing in
1142        // nothing but the header, produce different numbers of requests, which
1143        // they can only do if the number was read from the response.
1144        //
1145        // Both halves finish in the time one request takes. Five minutes is
1146        // clamped to `MAX_BACKOFF`, which is still longer than the budget, so
1147        // the call gives up rather than sleeping; zero is a wait of nothing.
1148        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        // A 503 with no header, which is the ordinary case.
1189        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        // A server asking for longer than any call budget is asking for the
1198        // budget to be spent asleep.
1199        assert_eq!(backoff(1, &asked(600)), MAX_BACKOFF);
1200        // Without a header, the wait doubles and then stops growing.
1201        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    // ---- concurrency -----------------------------------------------------
1222
1223    #[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    // ---- the size cap ----------------------------------------------------
1254
1255    #[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        // Chunked, so the cap cannot be checked before the transfer and has to
1276        // hold while the body arrives.
1277        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    // ---- shapes of a call ------------------------------------------------
1309
1310    #[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        // The reason `Answer` holds both halves (ADR-0003): a tool forwards
1328        // `raw` so the caller sees every field the control plane sent, and the
1329        // server reads `value` when it has to act. They come from one parse, so
1330        // a field in one is a field in the other.
1331        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        // The other thing ADR-0003 asks `Answer` to carry. The policy file is
1358        // the endpoint that needs it: a write has to quote the `ETag` it read.
1359        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        // The policy file is HuJSON, which is not JSON to parse, and its ETag
1413        // is what a later write has to quote.
1414        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    // ---- where a client may point ----------------------------------------
1465
1466    #[test]
1467    fn a_base_url_is_an_encrypted_host_and_nothing_more() {
1468        // Deliberately not "…is the control plane": the host is not pinned,
1469        // and a name that said so would be describing a guarantee this does
1470        // not make. What is checked is how the credential travels, which is
1471        // the three things below.
1472        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            // Plaintext to anywhere but this machine sends the credential in
1487            // the clear.
1488            "http://api.tailscale.com",
1489            "http://evil.example.com",
1490            // Not a URL, and a scheme that is not HTTP at all.
1491            "api.tailscale.com",
1492            "ftp://api.tailscale.com",
1493            // A base URL is a host; a path here would silently prefix every
1494            // call, which is a different server wearing the same name.
1495            "https://api.tailscale.com/api/v2",
1496            // A query or fragment would be prefixed to every call the same way.
1497            "https://api.tailscale.com/?tailnet=other",
1498            "https://api.tailscale.com#fragment",
1499            // Userinfo is a secret written where secrets get printed, and
1500            // this server sends its credential as a header regardless. The
1501            // host is `example.com`, which is accepted bare just above, so
1502            // userinfo is the only thing these two differ by.
1503            "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        // A trailing slash is how a URL is usually written down, and joining
1512        // it to a path that starts with one would double it.
1513        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        // An empty name means "not given" rather than an empty segment.
1545        assert_eq!(
1546            client.tailnet_path(Some("  "), "/devices"),
1547            "/api/v2/tailnet/-/devices"
1548        );
1549        // The point: a slash in a name is a character, not a boundary.
1550        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}