use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tailscale_cli::LocalBackend;
use crate::error::Redactor;
use crate::meta::Tier;
use crate::version::Version;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum PathPolicy {
#[default]
Unrestricted,
Within(Vec<PathBuf>),
}
impl PathPolicy {
#[must_use]
pub fn permits(&self, path: &Path) -> bool {
match self {
Self::Unrestricted => true,
Self::Within(roots) => {
!path.components().any(|c| c == Component::ParentDir)
&& roots.iter().any(|root| path.starts_with(root))
}
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SelfIdentity {
pub node_id: Option<String>,
pub numeric_id: Option<String>,
pub addresses: Vec<String>,
pub dns_name: Option<String>,
}
impl SelfIdentity {
pub fn matches(&self, target: &str) -> bool {
let target = target.trim().trim_end_matches('.');
if target.is_empty() {
return false;
}
let same = |candidate: &Option<String>| {
candidate
.as_deref()
.is_some_and(|c| c.trim_end_matches('.').eq_ignore_ascii_case(target))
};
same(&self.node_id)
|| same(&self.numeric_id)
|| same(&self.dns_name)
|| self.addresses.iter().any(|a| a == target)
|| self
.dns_name
.as_deref()
.and_then(|n| n.split('.').next())
.is_some_and(|short| short.eq_ignore_ascii_case(target))
}
}
pub const IDENTITY_FRESH_FOR: Duration = Duration::from_secs(60);
#[derive(Clone, Default)]
pub struct Identity {
held: Arc<Mutex<Held>>,
live: bool,
}
#[derive(Debug, Default)]
struct Held {
known: SelfIdentity,
read_at: Option<Instant>,
}
impl Identity {
pub fn probed(known: SelfIdentity) -> Self {
Self {
held: Arc::new(Mutex::new(Held {
known,
read_at: Some(Instant::now()),
})),
live: true,
}
}
pub fn fixed(known: SelfIdentity) -> Self {
Self {
held: Arc::new(Mutex::new(Held {
known,
read_at: None,
})),
live: false,
}
}
pub fn last_known(&self) -> SelfIdentity {
self.held
.lock()
.map(|held| held.known.clone())
.unwrap_or_default()
}
fn stale(&self) -> bool {
self.live
&& self.held.lock().is_ok_and(|held| {
held.read_at
.is_none_or(|at| at.elapsed() >= IDENTITY_FRESH_FOR)
})
}
fn store(&self, mut known: SelfIdentity) {
if let Ok(mut held) = self.held.lock() {
if known.numeric_id.is_none() && held.known.node_id == known.node_id {
known.numeric_id = held.known.numeric_id.take_if(|_| true);
}
held.known = known;
held.read_at = Some(Instant::now());
}
}
fn store_numeric(&self, numeric_id: String) {
if let Ok(mut held) = self.held.lock() {
held.known.numeric_id = Some(numeric_id);
}
}
}
impl std::fmt::Debug for Identity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Identity")
.field("known", &self.last_known())
.field("live", &self.live)
.finish()
}
}
impl From<SelfIdentity> for Identity {
fn from(known: SelfIdentity) -> Self {
Self::fixed(known)
}
}
fn strings(value: &serde_json::Value) -> Vec<String> {
value
.as_array()
.map(|items| {
items
.iter()
.filter_map(|item| Some(item.as_str()?.to_owned()))
.collect()
})
.unwrap_or_default()
}
#[derive(Clone, Debug)]
pub struct Device {
pub node_id: String,
pub name: String,
pub hostname: String,
pub addresses: Vec<String>,
pub tags: Vec<String>,
}
impl Device {
#[must_use]
pub fn short_name(&self) -> &str {
self.name.split('.').next().unwrap_or(&self.name)
}
#[must_use]
pub fn answers_to(&self, lowercased: &str) -> bool {
self.name.to_ascii_lowercase() == lowercased
|| self.hostname.to_ascii_lowercase() == lowercased
|| self.short_name().to_ascii_lowercase() == lowercased
|| self
.addresses
.iter()
.any(|address| address.to_ascii_lowercase() == lowercased)
}
}
type Listing = Arc<Mutex<Option<(Instant, Arc<[Device]>)>>>;
#[derive(Clone, Debug, Default)]
pub struct DeviceCache {
held: Listing,
}
impl DeviceCache {
const TTL: Duration = Duration::from_secs(10);
fn fresh(&self) -> Option<Arc<[Device]>> {
let held = self.held.lock().ok()?;
let (at, devices) = held.as_ref()?;
(at.elapsed() < Self::TTL).then(|| Arc::clone(devices))
}
fn put(&self, devices: &Arc<[Device]>) {
if let Ok(mut held) = self.held.lock() {
*held = Some((Instant::now(), Arc::clone(devices)));
}
}
}
#[derive(Clone)]
pub struct ToolContext {
pub local: Arc<dyn LocalBackend>,
pub(crate) tailnet: Option<tailscale_rest::Client>,
pub redactor: Redactor,
pub max_result_bytes: usize,
pub identity: Identity,
pub cli_version: Option<Version>,
pub paths: PathPolicy,
pub devices: DeviceCache,
pub max_tier: Tier,
}
impl ToolContext {
pub async fn tailnet_devices(&self) -> crate::error::ToolResult<Arc<[Device]>> {
if let Some(warm) = self.devices.fresh() {
return Ok(warm);
}
let client = self.tailnet()?;
let answer = client
.get(client.tailnet_path(None, "/devices"))
.send_as::<serde_json::Value>()
.await?;
let devices: Arc<[Device]> = answer["devices"]
.as_array()
.map(|listed| {
listed
.iter()
.filter_map(|device| {
Some(Device {
node_id: device["nodeId"].as_str()?.to_owned(),
name: device["name"].as_str().unwrap_or_default().to_owned(),
hostname: device["hostname"].as_str().unwrap_or_default().to_owned(),
addresses: strings(&device["addresses"]),
tags: strings(&device["tags"]),
})
})
.collect()
})
.unwrap_or_else(|| Vec::new().into());
self.devices.put(&devices);
Ok(devices)
}
pub async fn names_us(&self, target: &str) -> bool {
if self.identity.stale() {
self.identity
.store(crate::cli::probe_identity(self.local.as_ref()).await);
}
let known = self.identity.last_known();
if known.matches(target) {
return true;
}
let numeric = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
if known.numeric_id.is_some() || !numeric(target.trim()) {
return false;
}
let (Some(node_id), Some(client)) = (&known.node_id, self.tailnet.as_ref()) else {
return false;
};
let Ok(path) = crate::tools::tailnet_devices::device_path(node_id, "") else {
return false;
};
let Ok(device) = client.get(path).send_as::<serde_json::Value>().await else {
return false;
};
let Some(numeric_id) = device["id"].as_str() else {
return false;
};
self.identity.store_numeric(numeric_id.to_owned());
self.identity.last_known().matches(target)
}
pub fn tailnet(&self) -> crate::error::ToolResult<&tailscale_rest::Client> {
self.tailnet.as_ref().ok_or_else(|| {
crate::error::ToolError::backend_unavailable(
"the tailnet surface",
"no control-plane credential was found; set TAILSCALE_API_KEY, or \
TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET",
)
})
}
}
impl std::fmt::Debug for ToolContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ToolContext")
.field("tailnet", &self.tailnet.is_some())
.field("max_result_bytes", &self.max_result_bytes)
.field("identity", &self.identity)
.field("cli_version", &self.cli_version)
.field("max_tier", &self.max_tier)
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn identity() -> SelfIdentity {
SelfIdentity {
node_id: Some("n1234567CNTRL".to_owned()),
numeric_id: Some("92960230385".to_owned()),
addresses: vec!["100.64.0.1".to_owned(), "fd7a::1".to_owned()],
dns_name: Some("workstation.example-tailnet.ts.net.".to_owned()),
}
}
#[test]
fn a_node_is_recognised_by_any_name_the_api_accepts() {
let id = identity();
for name in [
"n1234567CNTRL",
"92960230385",
"100.64.0.1",
"fd7a::1",
"workstation.example-tailnet.ts.net",
"workstation.example-tailnet.ts.net.",
"workstation",
"WORKSTATION",
] {
assert!(id.matches(name), "{name} should name this node");
}
}
#[test]
fn another_node_is_not() {
let id = identity();
for name in [
"n7654321CNTRL",
"92960230386",
"nodekey:1111111111111111111111111111111111111111111111111111111111111111",
"100.64.0.2",
"laptop.example-tailnet.ts.net",
"laptop",
"",
" ",
] {
assert!(!id.matches(name), "{name} should not name this node");
}
}
#[test]
fn an_unknown_identity_matches_nothing() {
assert!(!SelfIdentity::default().matches("anything"));
}
#[test]
fn a_context_with_no_credential_names_the_variables_that_would_give_it_one() {
let ctx = crate::testing::context(std::sync::Arc::new(crate::testing::StubBackend::ok("")));
let error = ctx.tailnet().expect_err("no credential was configured");
let reported = serde_json::to_value(&error).expect("reportable");
assert_eq!(reported["code"], serde_json::json!("backend_unavailable"));
let message = reported["message"].as_str().expect("a message");
for variable in [
"TAILSCALE_API_KEY",
"TAILSCALE_OAUTH_CLIENT_ID",
"TAILSCALE_OAUTH_CLIENT_SECRET",
] {
assert!(message.contains(variable), "{message}");
}
}
}