use anyhow::{anyhow, Error, Result};
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
use reqwest::{Client, Response};
use serde::{Deserialize, Serialize};
use testcontainers::core::ExecCommand;
use testcontainers::{core::WaitFor, runners::AsyncRunner, GenericImage, ImageExt};
pub struct TailscaleClient {
pub base_url: String,
pub token: String,
client: Client,
}
impl TailscaleClient {
pub fn new(token: String) -> Self {
TailscaleClient {
base_url: "https://api.tailscale.com/api/v2".to_string(),
token,
client: Client::new(),
}
}
async fn get(&self, path: &str) -> Result<Response> {
let mut headers = HeaderMap::new();
let auth_value = format!("Bearer {}", self.token);
headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_value)?);
let url = format!("{}/{}", self.base_url, path);
let resp = self.client.get(url).headers(headers).send().await?;
Ok(resp)
}
pub async fn whoami(&self) -> anyhow::Result<WhoAmIResponse> {
let resp = self.get("whoami").await?;
if resp.status().is_success() {
let data: WhoAmIResponse = resp.json().await?;
Ok(data)
} else {
let error_body = resp.text().await?;
Err(anyhow!("Tailscale whoami endpoint error: {}", error_body))
}
}
pub async fn create_auth_key(
&self,
tailnet: &str,
all: bool,
req_body: &CreateAuthKeyRequest,
) -> Result<CreateAuthKeyResponse> {
let mut headers = HeaderMap::new();
let auth_value = format!("Bearer {}", self.token);
headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_value)?);
headers.insert("Content-Type", HeaderValue::from_static("application/json"));
let url = format!("{}/tailnet/{}/keys?all={}", self.base_url, tailnet, all);
let resp = self
.client
.post(url)
.headers(headers)
.json(req_body)
.send()
.await?;
if resp.status().is_success() {
let data = resp.json().await?;
Ok(data)
} else {
let error_body = resp.text().await?;
Err(anyhow!("Tailscale create_auth_key error: {}", error_body))
}
}
pub async fn list_devices(
&self,
tailnet: &str,
fields: Option<&str>,
) -> Result<ListDevicesResponse> {
let mut headers = HeaderMap::new();
let auth_value = format!("Bearer {}", self.token);
headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_value)?);
let mut url = format!("{}/tailnet/{}/devices", self.base_url, tailnet);
if let Some(f) = fields {
url.push_str(&format!("?fields={}", f));
}
let resp = self.client.get(url).headers(headers).send().await?;
if resp.status().is_success() {
let data: ListDevicesResponse = resp.json().await?;
Ok(data)
} else {
let error_body = resp.text().await?;
Err(anyhow!("Tailscale list_devices error: {}", error_body))
}
}
pub async fn find_device_by_name(
&self,
tailnet: &str,
name: &str,
fields: Option<&str>,
) -> Result<Option<TailnetDevice>> {
let devices_response = self.list_devices(tailnet, fields).await?;
println!(
"find_device_by_name: Searching for device matching '{}'",
name
);
for d in &devices_response.devices {
let raw_name = d.name.as_deref().unwrap_or("[no name]");
let split_part = raw_name.split('.').next().unwrap_or("");
println!(
" Device raw name: '{}', first_part='{}'",
raw_name, split_part
);
}
let name_lowercase = name.to_lowercase();
let device = devices_response.devices.into_iter().find(|d| {
let split_part = d
.name
.as_deref()
.map(|nm| nm.split('.').next().unwrap_or("").to_lowercase());
split_part.as_deref() == Some(name_lowercase.as_str())
});
match &device {
Some(dev) => {
println!(
"find_device_by_name: Matched device -> '{}'",
dev.name.as_deref().unwrap_or("")
);
}
None => {
println!("find_device_by_name: No device matched '{}'", name);
}
}
Ok(device)
}
pub async fn delete_device(
&self,
device_id: &str,
fields: Option<&str>,
) -> Result<Option<TailnetDevice>> {
let mut headers = HeaderMap::new();
let auth_value = format!("Bearer {}", self.token);
headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_value)?);
let mut url = format!("{}/device/{}", self.base_url, device_id);
if let Some(f) = fields {
url.push_str(&format!("?fields={}", f));
}
let resp = self.client.delete(url).headers(headers).send().await?;
if resp.status().is_success() {
let deleted_device: Option<TailnetDevice> = resp.json().await?;
Ok(deleted_device)
} else {
let error_body = resp.text().await?;
Err(anyhow!("Tailscale delete_device error: {}", error_body))
}
}
pub async fn remove_device_by_name(
&self,
tailnet: &str,
name: &str,
fields: Option<&str>,
) -> Result<Option<TailnetDevice>> {
if let Some(device) = self.find_device_by_name(tailnet, name, fields).await? {
if let Some(device_id) = device.nodeId.as_deref().or(device.id.as_deref()) {
let deleted = self.delete_device(device_id, fields).await?;
Ok(deleted)
} else {
Err(anyhow!("Device found, but it has no valid nodeId or id."))
}
} else {
Ok(None)
}
}
pub async fn wait_for_device_by_name(
&self,
tailnet: &str,
device_name: &str,
fields: Option<&str>,
max_retries: u32,
delay_secs: u64,
) -> Result<Option<TailnetDevice>> {
for attempt in 0..max_retries {
match self
.find_device_by_name(tailnet, device_name, fields)
.await?
{
Some(device) => {
println!("Found device '{}' on attempt {}", device_name, attempt + 1);
return Ok(Some(device));
}
None => {
println!(
"Attempt {} - device '{}' not found yet, sleeping...",
attempt + 1,
device_name
);
}
}
tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
}
println!(
"Reached maximum {} attempts, device '{}' not found.",
max_retries, device_name
);
Ok(None)
}
}
#[derive(Debug, Deserialize)]
pub struct WhoAmIResponse {
pub logged_in: bool,
#[serde(rename = "user")]
pub user_info: Option<UserInfo>,
#[serde(rename = "tailnet")]
pub tailnet_info: Option<TailnetInfo>,
}
#[derive(Debug, Deserialize)]
pub struct UserInfo {
pub login_name: Option<String>,
pub display_name: Option<String>,
pub profile_pic_url: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct TailnetInfo {
pub name: Option<String>,
pub magic_dns: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct CreateAuthKeyRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expirySeconds: Option<u64>,
pub capabilities: Capabilities,
}
#[derive(Debug, Serialize)]
pub struct Capabilities {
pub devices: Devices,
}
#[derive(Debug, Serialize)]
pub struct Devices {
#[serde(skip_serializing_if = "Option::is_none")]
pub create: Option<CreateOpts>,
}
#[derive(Debug, Serialize)]
pub struct CreateOpts {
#[serde(skip_serializing_if = "Option::is_none")]
pub reusable: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ephemeral: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preauthorized: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
pub struct CreateAuthKeyResponse {
pub id: Option<String>,
pub key: Option<String>,
pub created: Option<String>,
pub expires: Option<String>,
pub revoked: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capabilities: Option<AuthKeyCapabilities>,
pub description: Option<String>,
pub invalid: Option<bool>,
pub userId: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct AuthKeyCapabilities {
pub devices: Option<AuthKeyDevices>,
}
#[derive(Debug, Deserialize)]
pub struct AuthKeyDevices {
pub create: Option<AuthKeyCreate>,
}
#[derive(Debug, Deserialize)]
pub struct AuthKeyCreate {
pub reusable: Option<bool>,
pub ephemeral: Option<bool>,
pub preauthorized: Option<bool>,
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
pub struct ListDevicesResponse {
pub devices: Vec<TailnetDevice>,
}
#[derive(Debug, Deserialize)]
pub struct TailnetDevice {
pub addresses: Option<Vec<String>>,
pub id: Option<String>,
pub nodeId: Option<String>,
pub user: Option<String>,
pub name: Option<String>,
pub hostname: Option<String>,
pub clientVersion: Option<String>,
pub updateAvailable: Option<bool>,
pub os: Option<String>,
pub created: Option<String>,
pub lastSeen: Option<String>,
pub keyExpiryDisabled: Option<bool>,
pub expires: Option<String>,
pub authorized: Option<bool>,
pub isExternal: Option<bool>,
pub machineKey: Option<String>,
pub nodeKey: Option<String>,
pub blocksIncomingConnections: Option<bool>,
pub enabledRoutes: Option<Vec<String>>,
pub advertisedRoutes: Option<Vec<String>>,
pub clientConnectivity: Option<ClientConnectivity>,
pub tags: Option<Vec<String>>,
pub tailnetLockError: Option<String>,
pub tailnetLockKey: Option<String>,
pub postureIdentity: Option<PostureIdentity>,
}
#[derive(Debug, Deserialize)]
pub struct ClientConnectivity {
pub endpoints: Option<Vec<String>>,
pub latency: Option<std::collections::HashMap<String, LatencyInfo>>,
pub mappingVariesByDestIP: Option<bool>,
pub clientSupports: Option<ClientSupports>,
}
#[derive(Debug, Deserialize)]
pub struct LatencyInfo {
pub preferred: Option<bool>,
pub latencyMs: Option<f64>,
}
#[derive(Debug, Deserialize)]
pub struct ClientSupports {
pub hairPinning: Option<bool>,
pub ipv6: Option<bool>,
pub pcp: Option<bool>,
pub pmp: Option<bool>,
pub udp: Option<bool>,
pub upnp: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct PostureIdentity {
pub serialNumbers: Option<Vec<String>>,
}
#[tokio::test]
async fn test_tailscale_normal_in_docker() -> Result<()> {
let token = std::env::var("TAILSCALE_API_KEY").expect("Please set TAILSCALE_API_KEY env var.");
let tailnet = std::env::var("TAILSCALE_TAILNET").unwrap_or_else(|_| "-".to_string());
let client = TailscaleClient::new(token);
let request_body = CreateAuthKeyRequest {
description: Some("Docker test device normal".to_string()),
expirySeconds: None,
capabilities: Capabilities {
devices: Devices {
create: Some(CreateOpts {
reusable: Some(true),
ephemeral: Some(true),
preauthorized: Some(true),
tags: Some(vec![]),
}),
},
},
};
let response = client
.create_auth_key(&tailnet, true, &request_body)
.await?;
let auth_key = response
.key
.as_ref()
.expect("Expected 'key' in create_auth_key response");
let test_device_name = format!("testcontainer-device-normal-{}", rand::random::<u16>());
println!("Starting container with auth key: {}", auth_key);
let container = GenericImage::new("my-tailscale", "latest")
.with_env_var("TAILSCALE_AUTHKEY", auth_key)
.with_env_var("TAILSCALE_HOSTNAME", test_device_name.clone())
.start()
.await?;
let mut status = container
.exec(ExecCommand::new(vec![
"/bin/sh",
"-c",
"tailscale status --json",
]))
.await?;
let stdout = status.stdout_to_vec().await?;
println!(
"tailscale status --json:\n{}",
String::from_utf8_lossy(&stdout)
);
let device_opt = client
.wait_for_device_by_name(&tailnet, &test_device_name, None, 30, 2)
.await?;
assert!(
device_opt.is_some(),
"Device {} did not appear in list_devices within the expected time",
test_device_name
);
println!("Found device: {:?}", device_opt);
client
.remove_device_by_name(&tailnet, &test_device_name, None)
.await?;
println!("Deleted device");
Ok(())
}