use std::net::IpAddr;
use std::process::Stdio;
use std::time::Duration;
use futures_util::future::BoxFuture;
use serde::Deserialize;
const TAGGED_DEVICES_LOGIN: &str = "tagged-devices";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeerIdentity {
pub login: String,
pub tagged: bool,
pub node_name: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum WhoisError {
#[error("could not run `tailscale whois`: {0}")]
Spawn(#[source] std::io::Error),
#[error("`tailscale whois` exited with {status}: {stderr}")]
Exit {
status: String,
stderr: String,
},
#[error("could not parse `tailscale whois --json` output: {0}")]
Parse(String),
#[error("`tailscale whois` reported no user login for the node")]
NoLogin,
#[error("identity lookup timed out after {0:?}")]
Timeout(Duration),
}
pub trait PeerResolver: Send + Sync + 'static {
fn whois(&self, ip: IpAddr) -> BoxFuture<'_, Result<PeerIdentity, WhoisError>>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct TailscaleCliResolver;
impl PeerResolver for TailscaleCliResolver {
fn whois(&self, ip: IpAddr) -> BoxFuture<'_, Result<PeerIdentity, WhoisError>> {
Box::pin(async move {
let out = tokio::process::Command::new("tailscale")
.args(["whois", "--json", &ip.to_string()])
.stdin(Stdio::null())
.kill_on_drop(true)
.output()
.await
.map_err(WhoisError::Spawn)?;
if !out.status.success() {
return Err(WhoisError::Exit {
status: out.status.to_string(),
stderr: String::from_utf8_lossy(&out.stderr).trim().to_owned(),
});
}
parse_whois_json(&out.stdout)
})
}
}
#[derive(Deserialize)]
struct WhoisJson {
#[serde(rename = "Node")]
node: Option<WhoisNode>,
#[serde(rename = "UserProfile")]
user_profile: Option<WhoisUser>,
}
#[derive(Deserialize)]
struct WhoisNode {
#[serde(rename = "Tags", default)]
tags: Option<Vec<String>>,
#[serde(rename = "Name", default)]
name: Option<String>,
}
#[derive(Deserialize)]
struct WhoisUser {
#[serde(rename = "LoginName", default)]
login_name: String,
}
pub fn parse_whois_json(bytes: &[u8]) -> Result<PeerIdentity, WhoisError> {
let parsed: WhoisJson =
serde_json::from_slice(bytes).map_err(|e| WhoisError::Parse(e.to_string()))?;
let login = parsed
.user_profile
.map(|u| u.login_name.trim().to_owned())
.filter(|l| !l.is_empty())
.ok_or(WhoisError::NoLogin)?;
let (tags, name) = parsed.node.map_or((None, None), |n| (n.tags, n.name));
let has_tags = tags.is_some_and(|tags| !tags.is_empty());
let tagged = has_tags || login == TAGGED_DEVICES_LOGIN;
let node_name = name
.map(|n| n.trim().trim_end_matches('.').to_ascii_lowercase())
.filter(|n| !n.is_empty());
Ok(PeerIdentity {
login,
tagged,
node_name,
})
}