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
9pub struct TailscaleClient {
11 pub base_url: String,
12 pub token: String,
13 client: Client,
14}
15
16impl TailscaleClient {
17 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 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 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 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 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 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 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 println!(
131 "find_device_by_name: Searching for device matching '{}'",
132 name
133 );
134
135 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 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 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 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 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 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 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 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 Ok(None)
249 }
250 }
251
252 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#[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#[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#[derive(Debug, Deserialize)]
312pub struct TailnetInfo {
313 pub name: Option<String>,
314 pub magic_dns: Option<bool>,
315}
316
317#[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#[derive(Debug, Serialize)]
332pub struct Capabilities {
333 pub devices: Devices,
334}
335
336#[derive(Debug, Serialize)]
338pub struct Devices {
339 #[serde(skip_serializing_if = "Option::is_none")]
340 pub create: Option<CreateOpts>,
341}
342
343#[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#[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#[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#[derive(Debug, Deserialize)]
397pub struct ListDevicesResponse {
398 pub devices: Vec<TailnetDevice>,
399}
400
401#[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#[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#[derive(Debug, Deserialize)]
442pub struct LatencyInfo {
443 pub preferred: Option<bool>,
444 pub latencyMs: Option<f64>,
445}
446
447#[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#[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 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 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 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 .start()
507 .await?;
508
509 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 let device_opt = client
527 .wait_for_device_by_name(&tailnet, &test_device_name, None, 30, 2)
528 .await?;
529
530 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 client
541 .remove_device_by_name(&tailnet, &test_device_name, None)
542 .await?;
543
544 println!("Deleted device");
545
546 Ok(())
547}