use std::collections::HashMap;
use std::io::Read as _;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use log::debug;
use serde::Deserialize;
use base64::Engine as _;
use super::{Provider, ProviderError, ProviderHost, map_ureq_error, strip_cidr};
pub struct Tailscale;
#[derive(Deserialize)]
struct CliStatus {
#[serde(rename = "Peer")]
#[serde(default)]
peer: HashMap<String, CliPeer>,
}
#[derive(Deserialize)]
struct CliPeer {
#[serde(rename = "ID")]
id: String,
#[serde(rename = "HostName")]
host_name: String,
#[serde(rename = "TailscaleIPs")]
#[serde(default)]
tailscale_ips: Vec<String>,
#[serde(rename = "OS")]
#[serde(default)]
os: String,
#[serde(rename = "Online")]
#[serde(default)]
online: Option<bool>,
#[serde(rename = "Tags")]
#[serde(default)]
tags: Vec<String>,
}
#[derive(Deserialize)]
struct ApiResponse {
devices: Vec<ApiDevice>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiDevice {
node_id: String,
hostname: String,
name: String,
#[serde(default)]
addresses: Vec<String>,
#[serde(default)]
os: String,
#[serde(default = "default_authorized")]
authorized: bool,
#[serde(default)]
connected_to_control: bool,
#[serde(default, deserialize_with = "deserialize_null_vec")]
tags: Vec<String>,
}
fn default_authorized() -> bool {
true
}
fn deserialize_null_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<Vec<String>>::deserialize(deserializer).map(|v| v.unwrap_or_default())
}
fn select_ip(ips: &[String]) -> Option<String> {
if let Some(ip) = ips.iter().find(|ip| ip.starts_with("100.")) {
return Some(strip_cidr(ip).to_string());
}
ips.first().map(|ip| strip_cidr(ip).to_string())
}
fn strip_tag_prefix(tag: &str) -> String {
tag.strip_prefix("tag:").unwrap_or(tag).to_string()
}
fn find_tailscale_binary() -> Result<PathBuf, ProviderError> {
let found = std::process::Command::new("sh")
.args(["-c", "command -v tailscale"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output();
if let Ok(output) = found {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Ok(PathBuf::from(path));
}
}
}
let macos_path = PathBuf::from("/Applications/Tailscale.app/Contents/MacOS/Tailscale");
if macos_path.exists() {
return Ok(macos_path);
}
Err(ProviderError::Execute(
"Tailscale CLI not found. Install from https://tailscale.com/download or add it to PATH."
.to_string(),
))
}
impl Provider for Tailscale {
fn name(&self) -> &str {
"tailscale"
}
fn short_label(&self) -> &str {
"ts"
}
fn fetch_hosts_cancellable(
&self,
token: &str,
cancel: &AtomicBool,
) -> Result<Vec<ProviderHost>, ProviderError> {
if token.is_empty() {
self.fetch_from_cli(cancel)
} else {
self.fetch_from_api(token, cancel)
}
}
}
impl Tailscale {
fn fetch_from_cli(&self, cancel: &AtomicBool) -> Result<Vec<ProviderHost>, ProviderError> {
let binary = find_tailscale_binary()?;
let mut child = std::process::Command::new(&binary)
.args(["status", "--json"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| ProviderError::Execute(format!("Failed to run tailscale: {}", e)))?;
let stdout_pipe = child.stdout.take();
let stdout_handle = std::thread::spawn(move || -> Result<String, String> {
match stdout_pipe {
Some(mut pipe) => {
let mut buf = String::new();
pipe.read_to_string(&mut buf)
.map_err(|e| format!("Failed to read tailscale stdout: {}", e))?;
Ok(buf)
}
None => Err("No stdout from tailscale".to_string()),
}
});
let start = Instant::now();
let timeout = Duration::from_secs(30);
let exit_err: Option<ProviderError> = loop {
if cancel.load(Ordering::Relaxed) {
let _ = child.kill();
let _ = child.wait();
break Some(ProviderError::Cancelled);
}
match child.try_wait() {
Ok(Some(status)) => {
if !status.success() {
let stderr = child
.stderr
.take()
.map(|mut s| {
let mut buf = String::new();
if let Err(e) = s.read_to_string(&mut buf) {
debug!("[external] Failed to read tailscale stderr: {e}");
}
buf
})
.unwrap_or_default();
break Some(ProviderError::Execute(format!(
"tailscale status failed: {}",
stderr.trim()
)));
}
break None;
}
Ok(None) => {
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
break Some(ProviderError::Execute(
"Tailscale CLI timed out after 30s.".to_string(),
));
}
std::thread::sleep(Duration::from_millis(100));
}
Err(e) => {
let _ = child.kill();
let _ = child.wait();
break Some(ProviderError::Execute(format!(
"Failed to wait for tailscale: {}",
e
)));
}
}
};
let stdout_result = stdout_handle.join();
if let Some(err) = exit_err {
return Err(err);
}
let stdout_data = stdout_result
.map_err(|_| ProviderError::Parse("stdout reader thread panicked".to_string()))?
.map_err(ProviderError::Parse)?;
let status: CliStatus = serde_json::from_str(&stdout_data).map_err(|e| {
ProviderError::Parse(format!("Failed to parse tailscale output: {}", e))
})?;
Self::hosts_from_cli(status)
}
fn hosts_from_cli(status: CliStatus) -> Result<Vec<ProviderHost>, ProviderError> {
let mut hosts = Vec::new();
let mut peers: Vec<_> = status.peer.into_iter().collect();
peers.sort_by(|a, b| a.0.cmp(&b.0));
for (_key, peer) in peers {
let ip = match select_ip(&peer.tailscale_ips) {
Some(ip) => ip,
None => continue,
};
let tags: Vec<String> = peer.tags.iter().map(|t| strip_tag_prefix(t)).collect();
let status_str = match peer.online {
Some(true) => "online",
Some(false) => "offline",
None => "unknown",
};
let mut metadata = Vec::new();
if !peer.os.is_empty() {
metadata.push(("os".to_string(), peer.os.clone()));
}
metadata.push(("status".to_string(), status_str.to_string()));
hosts.push(ProviderHost {
server_id: peer.id,
name: peer.host_name,
ip,
tags,
metadata,
});
}
Ok(hosts)
}
fn fetch_from_api(
&self,
token: &str,
cancel: &AtomicBool,
) -> Result<Vec<ProviderHost>, ProviderError> {
if token.starts_with("tskey-auth-") {
return Err(ProviderError::Execute(
"This is a device auth key, not an API key. Use a key starting with tskey-api-."
.to_string(),
));
}
if cancel.load(Ordering::Relaxed) {
return Err(ProviderError::Cancelled);
}
let agent = super::http_agent();
let auth_header = if token.starts_with("tskey-") {
let encoded = base64::engine::general_purpose::STANDARD.encode(format!("{}:", token));
format!("Basic {}", encoded)
} else {
format!("Bearer {}", token)
};
let resp: ApiResponse = agent
.get("https://api.tailscale.com/api/v2/tailnet/-/devices?fields=all")
.header("Authorization", &auth_header)
.call()
.map_err(map_ureq_error)?
.body_mut()
.read_json()
.map_err(|e| ProviderError::Parse(e.to_string()))?;
Self::hosts_from_api(resp)
}
fn hosts_from_api(resp: ApiResponse) -> Result<Vec<ProviderHost>, ProviderError> {
let mut hosts = Vec::new();
for device in resp.devices {
if !device.authorized {
continue;
}
let ip = match select_ip(&device.addresses) {
Some(ip) => ip,
None => continue,
};
let name = if device.hostname.is_empty() {
device
.name
.split('.')
.next()
.unwrap_or(&device.name)
.to_string()
} else {
device.hostname.clone()
};
let tags: Vec<String> = device.tags.iter().map(|t| strip_tag_prefix(t)).collect();
let mut metadata = Vec::new();
if !device.os.is_empty() {
metadata.push(("os".to_string(), device.os.clone()));
}
let status_str = if device.connected_to_control {
"online"
} else {
"offline"
};
metadata.push(("status".to_string(), status_str.to_string()));
hosts.push(ProviderHost {
server_id: device.node_id,
name,
ip,
tags,
metadata,
});
}
Ok(hosts)
}
}
#[cfg(test)]
#[path = "tailscale_tests.rs"]
mod tests;