tailscale_client/
lib.rs

1use anyhow::{anyhow, Error, Result};
2use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
3use reqwest::{Client, Response};
4use serde::{Deserialize, Serialize};
5use testcontainers::core::ExecCommand;
6
7use testcontainers::{core::WaitFor, runners::AsyncRunner, GenericImage, ImageExt};
8
9/// A client for interacting with Tailscale's v2 API.
10pub struct TailscaleClient {
11    pub base_url: String,
12    pub token: String,
13    client: Client,
14}
15
16impl TailscaleClient {
17    /// Creates a new TailscaleClient with the given token, automatically
18    /// setting the base URL to https://api.tailscale.com/api/v2
19    pub fn new(token: String) -> Self {
20        TailscaleClient {
21            base_url: "https://api.tailscale.com/api/v2".to_string(),
22            token,
23            client: Client::new(),
24        }
25    }
26
27    /// Constructs an authorized GET request for the given path.
28    async fn get(&self, path: &str) -> Result<Response> {
29        let mut headers = HeaderMap::new();
30        let auth_value = format!("Bearer {}", self.token);
31        headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_value)?);
32
33        let url = format!("{}/{}", self.base_url, path);
34        let resp = self.client.get(url).headers(headers).send().await?;
35        Ok(resp)
36    }
37
38    /// Example method to call the `/whoami` endpoint which returns information
39    /// about the current user and their Tailnets.
40    pub async fn whoami(&self) -> anyhow::Result<WhoAmIResponse> {
41        let resp = self.get("whoami").await?;
42        if resp.status().is_success() {
43            let data: WhoAmIResponse = resp.json().await?;
44            Ok(data)
45        } else {
46            let error_body = resp.text().await?;
47            Err(anyhow!("Tailscale whoami endpoint error: {}", error_body))
48        }
49    }
50
51    /// Creates a new auth key in the specified tailnet, returning the newly generated key.
52    /// The `all` parameter is optional in the API, but here we surface it directly
53    /// to match the Tailscale docs example (e.g., `?all=true`).
54    pub async fn create_auth_key(
55        &self,
56        tailnet: &str,
57        all: bool,
58        req_body: &CreateAuthKeyRequest,
59    ) -> Result<CreateAuthKeyResponse> {
60        let mut headers = HeaderMap::new();
61        let auth_value = format!("Bearer {}", self.token);
62        headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_value)?);
63        headers.insert("Content-Type", HeaderValue::from_static("application/json"));
64
65        let url = format!("{}/tailnet/{}/keys?all={}", self.base_url, tailnet, all);
66
67        let resp = self
68            .client
69            .post(url)
70            .headers(headers)
71            .json(req_body)
72            .send()
73            .await?;
74
75        if resp.status().is_success() {
76            let data = resp.json().await?;
77            Ok(data)
78        } else {
79            let error_body = resp.text().await?;
80            Err(anyhow!("Tailscale create_auth_key error: {}", error_body))
81        }
82    }
83
84    /// Lists the devices in a tailnet.
85    ///
86    /// The `fields` parameter can be "all" to return all device fields, or "default" to only get
87    /// limited fields (addresses, id, nodeId, user, name, hostname, etc). If `fields` is `None`,
88    /// then no query parameter is applied, and the default fields set is returned.
89    ///
90    /// For details, see https://tailscale.com/kb/api#list-tailnet-devices.
91    pub async fn list_devices(
92        &self,
93        tailnet: &str,
94        fields: Option<&str>,
95    ) -> Result<ListDevicesResponse> {
96        let mut headers = HeaderMap::new();
97        let auth_value = format!("Bearer {}", self.token);
98        headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_value)?);
99
100        // Build the URL, appending "?fields=___" if needed
101        let mut url = format!("{}/tailnet/{}/devices", self.base_url, tailnet);
102        if let Some(f) = fields {
103            url.push_str(&format!("?fields={}", f));
104        }
105
106        let resp = self.client.get(url).headers(headers).send().await?;
107        if resp.status().is_success() {
108            let data: ListDevicesResponse = resp.json().await?;
109            Ok(data)
110        } else {
111            let error_body = resp.text().await?;
112            Err(anyhow!("Tailscale list_devices error: {}", error_body))
113        }
114    }
115
116    /// Finds a single device by `name` in the specified `tailnet` using the `list_devices()` call.
117    /// Returns `Ok(Some(device))` if found, `Ok(None)` if not found, or an error otherwise.
118    ///
119    /// You may pass `fields` as `Some("all")` to request all fields, or `None` (the default)
120    /// to request the limited set. See `list_devices()` for more details.
121    pub async fn find_device_by_name(
122        &self,
123        tailnet: &str,
124        name: &str,
125        fields: Option<&str>,
126    ) -> Result<Option<TailnetDevice>> {
127        let devices_response = self.list_devices(tailnet, fields).await?;
128
129        // Debug: Print out the name we're trying to match:
130        println!(
131            "find_device_by_name: Searching for device matching '{}'",
132            name
133        );
134
135        // Debug: Print out all devices' names, along with their first segment (split by '.')
136        for d in &devices_response.devices {
137            let raw_name = d.name.as_deref().unwrap_or("[no name]");
138            let split_part = raw_name.split('.').next().unwrap_or("");
139            println!(
140                "  Device raw name: '{}', first_part='{}'",
141                raw_name, split_part
142            );
143        }
144
145        // Now actually do the find with case-insensitive comparison:
146        let name_lowercase = name.to_lowercase();
147        let device = devices_response.devices.into_iter().find(|d| {
148            let split_part = d
149                .name
150                .as_deref()
151                .map(|nm| nm.split('.').next().unwrap_or("").to_lowercase());
152            split_part.as_deref() == Some(name_lowercase.as_str())
153        });
154
155        // Debug: Print if we found a device or not:
156        match &device {
157            Some(dev) => {
158                println!(
159                    "find_device_by_name: Matched device -> '{}'",
160                    dev.name.as_deref().unwrap_or("")
161                );
162            }
163            None => {
164                println!("find_device_by_name: No device matched '{}'", name);
165            }
166        }
167
168        Ok(device)
169    }
170
171    /// Deletes the specified device from the tailnet.
172    /// The device must belong to the requesting user's tailnet. Deleting devices
173    /// shared with the tailnet is not supported.
174    ///
175    /// # Arguments
176    ///
177    /// * `device_id` - The ID of the device to delete. This can be either the `nodeId`
178    ///   or the numeric `id`.
179    /// * `fields` - If provided, appends `?fields=default` or `?fields=all` to the
180    ///   request. Defaults to the limited fields if omitted.
181    ///
182    /// # Returns
183    ///
184    /// * `Ok(TailnetDevice)` if the deletion is successful (Tailscale returns the deleted
185    ///   device object in the response).
186    /// * An error otherwise.
187    pub async fn delete_device(
188        &self,
189        device_id: &str,
190        fields: Option<&str>,
191    ) -> Result<Option<TailnetDevice>> {
192        let mut headers = HeaderMap::new();
193        let auth_value = format!("Bearer {}", self.token);
194        headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_value)?);
195
196        // Build the URL, appending "?fields=___" if needed
197        let mut url = format!("{}/device/{}", self.base_url, device_id);
198        if let Some(f) = fields {
199            url.push_str(&format!("?fields={}", f));
200        }
201
202        let resp = self.client.delete(url).headers(headers).send().await?;
203
204        if resp.status().is_success() {
205            // Since the Tailscale docs or actual API might hand us null, parse as Option
206            let deleted_device: Option<TailnetDevice> = resp.json().await?;
207            Ok(deleted_device)
208        } else {
209            let error_body = resp.text().await?;
210            Err(anyhow!("Tailscale delete_device error: {}", error_body))
211        }
212    }
213
214    /// Removes a device by its first name component if it exists on the specified tailnet.
215    /// Returns an `Ok(Some(TailnetDevice))` containing the deleted device if it was found
216    /// and removed, or `Ok(None)` if the device was not found. If Tailscale returns an error,
217    /// an Err(...) is returned.
218    ///
219    /// # Arguments
220    ///
221    /// * `tailnet` - The name of the tailnet.
222    /// * `name` - The device's first name component. For example, passing "my-dev"
223    ///   will match device names like "my-dev.example.com".
224    /// * `fields` - If provided, e.g. "all", returns more fields in the device object
225    ///   from the Tailscale API. Defaults to limited fields if `None`.
226    ///
227    /// # Returns
228    ///
229    /// * `Ok(Some(TailnetDevice))` if the device was found and successfully deleted.
230    /// * `Ok(None)` if the device was not found.
231    /// * An error otherwise.
232    pub async fn remove_device_by_name(
233        &self,
234        tailnet: &str,
235        name: &str,
236        fields: Option<&str>,
237    ) -> Result<Option<TailnetDevice>> {
238        if let Some(device) = self.find_device_by_name(tailnet, name, fields).await? {
239            // We can use either nodeId or id for deletion; prefer nodeId if present.
240            if let Some(device_id) = device.nodeId.as_deref().or(device.id.as_deref()) {
241                let deleted = self.delete_device(device_id, fields).await?;
242                Ok(deleted)
243            } else {
244                Err(anyhow!("Device found, but it has no valid nodeId or id."))
245            }
246        } else {
247            // Device not found
248            Ok(None)
249        }
250    }
251
252    /// Waits for a device to appear in the specified tailnet, matching by its first name component.
253    /// Polls `find_device_by_name` up to `max_retries` times, sleeping `delay_secs` each time
254    /// before giving up. Returns `Ok(Some(TailnetDevice))` if found, or `Ok(None)` if not found.
255    pub async fn wait_for_device_by_name(
256        &self,
257        tailnet: &str,
258        device_name: &str,
259        fields: Option<&str>,
260        max_retries: u32,
261        delay_secs: u64,
262    ) -> Result<Option<TailnetDevice>> {
263        for attempt in 0..max_retries {
264            match self
265                .find_device_by_name(tailnet, device_name, fields)
266                .await?
267            {
268                Some(device) => {
269                    println!("Found device '{}' on attempt {}", device_name, attempt + 1);
270                    return Ok(Some(device));
271                }
272                None => {
273                    println!(
274                        "Attempt {} - device '{}' not found yet, sleeping...",
275                        attempt + 1,
276                        device_name
277                    );
278                }
279            }
280
281            tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
282        }
283
284        println!(
285            "Reached maximum {} attempts, device '{}' not found.",
286            max_retries, device_name
287        );
288        Ok(None)
289    }
290}
291
292/// Example response from `/whoami`
293#[derive(Debug, Deserialize)]
294pub struct WhoAmIResponse {
295    pub logged_in: bool,
296    #[serde(rename = "user")]
297    pub user_info: Option<UserInfo>,
298    #[serde(rename = "tailnet")]
299    pub tailnet_info: Option<TailnetInfo>,
300}
301
302/// Minimal user info
303#[derive(Debug, Deserialize)]
304pub struct UserInfo {
305    pub login_name: Option<String>,
306    pub display_name: Option<String>,
307    pub profile_pic_url: Option<String>,
308}
309
310/// Minimal tailnet info
311#[derive(Debug, Deserialize)]
312pub struct TailnetInfo {
313    pub name: Option<String>,
314    pub magic_dns: Option<bool>,
315}
316
317/// Request body for creating an auth key.
318/// Adjust fields as needed based on Tailscale's docs.
319#[derive(Debug, Serialize)]
320pub struct CreateAuthKeyRequest {
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub description: Option<String>,
323
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub expirySeconds: Option<u64>,
326
327    pub capabilities: Capabilities,
328}
329
330/// The `capabilities` definition for Tailscale's auth key creation.
331#[derive(Debug, Serialize)]
332pub struct Capabilities {
333    pub devices: Devices,
334}
335
336/// Minimal required field under `devices`, though you can add sub-fields as needed.
337#[derive(Debug, Serialize)]
338pub struct Devices {
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub create: Option<CreateOpts>,
341}
342
343/// Example subfields that can be used when creating a device auth key.
344#[derive(Debug, Serialize)]
345pub struct CreateOpts {
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub reusable: Option<bool>,
348
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub ephemeral: Option<bool>,
351
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub preauthorized: Option<bool>,
354
355    #[serde(skip_serializing_if = "Option::is_none")]
356    pub tags: Option<Vec<String>>,
357}
358
359/// Response body from creating an auth key.
360#[derive(Debug, Deserialize)]
361pub struct CreateAuthKeyResponse {
362    pub id: Option<String>,
363    pub key: Option<String>,
364    pub created: Option<String>,
365    pub expires: Option<String>,
366    pub revoked: Option<String>,
367
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub capabilities: Option<AuthKeyCapabilities>,
370
371    pub description: Option<String>,
372    pub invalid: Option<bool>,
373    pub userId: Option<String>,
374}
375
376/// Nested capabilities info in the create-auth-key response.
377#[derive(Debug, Deserialize)]
378pub struct AuthKeyCapabilities {
379    pub devices: Option<AuthKeyDevices>,
380}
381
382#[derive(Debug, Deserialize)]
383pub struct AuthKeyDevices {
384    pub create: Option<AuthKeyCreate>,
385}
386
387#[derive(Debug, Deserialize)]
388pub struct AuthKeyCreate {
389    pub reusable: Option<bool>,
390    pub ephemeral: Option<bool>,
391    pub preauthorized: Option<bool>,
392    pub tags: Option<Vec<String>>,
393}
394
395/// Response from `GET /tailnet/{tailnet}/devices`
396#[derive(Debug, Deserialize)]
397pub struct ListDevicesResponse {
398    pub devices: Vec<TailnetDevice>,
399}
400
401/// Represents a single device entry from the tailnet devices list.
402#[derive(Debug, Deserialize)]
403pub struct TailnetDevice {
404    pub addresses: Option<Vec<String>>,
405    pub id: Option<String>,
406    pub nodeId: Option<String>,
407    pub user: Option<String>,
408    pub name: Option<String>,
409    pub hostname: Option<String>,
410    pub clientVersion: Option<String>,
411    pub updateAvailable: Option<bool>,
412    pub os: Option<String>,
413    pub created: Option<String>,
414    pub lastSeen: Option<String>,
415    pub keyExpiryDisabled: Option<bool>,
416    pub expires: Option<String>,
417    pub authorized: Option<bool>,
418    pub isExternal: Option<bool>,
419    pub machineKey: Option<String>,
420    pub nodeKey: Option<String>,
421    pub blocksIncomingConnections: Option<bool>,
422    pub enabledRoutes: Option<Vec<String>>,
423    pub advertisedRoutes: Option<Vec<String>>,
424    pub clientConnectivity: Option<ClientConnectivity>,
425    pub tags: Option<Vec<String>>,
426    pub tailnetLockError: Option<String>,
427    pub tailnetLockKey: Option<String>,
428    pub postureIdentity: Option<PostureIdentity>,
429}
430
431/// Nested client connectivity data.
432#[derive(Debug, Deserialize)]
433pub struct ClientConnectivity {
434    pub endpoints: Option<Vec<String>>,
435    pub latency: Option<std::collections::HashMap<String, LatencyInfo>>,
436    pub mappingVariesByDestIP: Option<bool>,
437    pub clientSupports: Option<ClientSupports>,
438}
439
440/// Per-exit-node latency info.
441#[derive(Debug, Deserialize)]
442pub struct LatencyInfo {
443    pub preferred: Option<bool>,
444    pub latencyMs: Option<f64>,
445}
446
447/// Flags indicating which network features the client supports.
448#[derive(Debug, Deserialize)]
449pub struct ClientSupports {
450    pub hairPinning: Option<bool>,
451    pub ipv6: Option<bool>,
452    pub pcp: Option<bool>,
453    pub pmp: Option<bool>,
454    pub udp: Option<bool>,
455    pub upnp: Option<bool>,
456}
457
458/// Helps encode any posture/identity info.
459#[derive(Debug, Deserialize)]
460pub struct PostureIdentity {
461    pub serialNumbers: Option<Vec<String>>,
462}
463
464#[tokio::test]
465async fn test_tailscale_normal_in_docker() -> Result<()> {
466    // 1) Read your Tailscale API token + tailnet from env
467    let token = std::env::var("TAILSCALE_API_KEY").expect("Please set TAILSCALE_API_KEY env var.");
468    let tailnet = std::env::var("TAILSCALE_TAILNET").unwrap_or_else(|_| "-".to_string());
469
470    let client = TailscaleClient::new(token);
471
472    // 2) Create an auth key (optionally remove ephemeral & preauthorized)
473    let request_body = CreateAuthKeyRequest {
474        description: Some("Docker test device normal".to_string()),
475        expirySeconds: None,
476        capabilities: Capabilities {
477            devices: Devices {
478                create: Some(CreateOpts {
479                    reusable: Some(true),
480                    ephemeral: Some(true),
481                    preauthorized: Some(true),
482                    tags: Some(vec![]),
483                }),
484            },
485        },
486    };
487    let response = client
488        .create_auth_key(&tailnet, true, &request_body)
489        .await?;
490
491    let auth_key = response
492        .key
493        .as_ref()
494        .expect("Expected 'key' in create_auth_key response");
495
496    let test_device_name = format!("testcontainer-device-normal-{}", rand::random::<u16>());
497
498    println!("Starting container with auth key: {}", auth_key);
499
500    // Instead of calling tailscale up here, just pass environment variables:
501    let container = GenericImage::new("my-tailscale", "latest")
502        .with_env_var("TAILSCALE_AUTHKEY", auth_key)
503        .with_env_var("TAILSCALE_HOSTNAME", test_device_name.clone())
504        // If your entrypoint uses a socket path, this is optional
505        // .with_cap_add("NET_ADMIN") // only if you're using a real tun device, not userspace
506        .start()
507        .await?;
508
509    // At this point, Tailscale should already be up inside the container.
510    // We may just check logs or run 'tailscale status' for verification:
511    let mut status = container
512        .exec(ExecCommand::new(vec![
513            "/bin/sh",
514            "-c",
515            "tailscale status --json",
516        ]))
517        .await?;
518
519    let stdout = status.stdout_to_vec().await?;
520    println!(
521        "tailscale status --json:\n{}",
522        String::from_utf8_lossy(&stdout)
523    );
524
525    // 7) Wait for up to 30 attempts, sleeping 2s each
526    let device_opt = client
527        .wait_for_device_by_name(&tailnet, &test_device_name, None, 30, 2)
528        .await?;
529
530    // Ensure the device is actually found
531    assert!(
532        device_opt.is_some(),
533        "Device {} did not appear in list_devices within the expected time",
534        test_device_name
535    );
536
537    println!("Found device: {:?}", device_opt);
538
539    // 8) Once found, we can delete
540    client
541        .remove_device_by_name(&tailnet, &test_device_name, None)
542        .await?;
543
544    println!("Deleted device");
545
546    Ok(())
547}