use std::collections::HashMap;
use std::net::IpAddr;
use tailscale_cli::{ExecError, Invocation, Output};
use crate::context::{SelfIdentity, ToolContext};
use crate::error::{ToolError, ToolResult};
use crate::meta::ToolMeta;
use crate::version::{Version, satisfies};
pub async fn run(ctx: &ToolContext, meta: &ToolMeta, invocation: Invocation) -> ToolResult<Output> {
let display = displayed(ctx, &invocation);
let output = ctx
.local
.run(invocation)
.await
.map_err(|e| exec_error(ctx, &display, e))?;
if output.success() {
return Ok(output);
}
Err(command_failure(ctx, meta, &display, &output))
}
pub async fn run_tolerant(
ctx: &ToolContext,
meta: &ToolMeta,
invocation: Invocation,
) -> ToolResult<Output> {
let display = displayed(ctx, &invocation);
let output = ctx
.local
.run(invocation)
.await
.map_err(|e| exec_error(ctx, &display, e))?;
if output.success() {
return Ok(output);
}
let stderr = ctx.redactor.apply(&output.stderr);
if is_unrecognised(&stderr) {
return Err(version_error(ctx, meta));
}
if needs_operator(&stderr) {
return Err(ToolError::needs_operator(&stderr));
}
Ok(output)
}
pub async fn run_text(
ctx: &ToolContext,
meta: &ToolMeta,
invocation: Invocation,
) -> ToolResult<String> {
let output = run(ctx, meta, invocation).await?;
Ok(output.stdout_str().into_owned())
}
pub(crate) fn displayed(ctx: &ToolContext, invocation: &Invocation) -> String {
ctx.redactor.apply(&invocation.display()).into_owned()
}
fn exec_error(ctx: &ToolContext, display: &str, error: ExecError) -> ToolError {
let told = |error: &ExecError| ctx.redactor.apply(&error.to_string()).into_owned();
match error {
ExecError::BinaryNotFound { .. }
| ExecError::BinaryNotExecutable { .. }
| ExecError::Spawn { .. } => {
ToolError::backend_unavailable("the local surface", &told(&error))
}
ExecError::Timeout {
timeout, printed, ..
} => ToolError::timeout(display, timeout.as_secs(), &ctx.redactor.apply(&printed)),
ExecError::Io { .. } | ExecError::SecretFile(_) => {
ToolError::new(crate::error::ErrorCode::CliFailed, told(&error))
}
}
}
pub fn command_failure(
ctx: &ToolContext,
meta: &ToolMeta,
display: &str,
output: &Output,
) -> ToolError {
let stderr = ctx.redactor.apply(&output.stderr);
if is_unrecognised(&stderr) {
return version_error(ctx, meta);
}
if needs_operator(&stderr) {
return ToolError::needs_operator(&stderr);
}
if is_not_found(&stderr) {
return ToolError::not_found(stderr.trim());
}
ToolError::cli_failed(display, output.exit_code, &stderr)
}
fn is_unrecognised(stderr: &str) -> bool {
const MARKERS: &[&str] = &[
"flag provided but not defined",
"unknown flag",
"unknown subcommand",
"unknown command",
"is not a tailscale command",
"unrecognized command",
];
let lowered = stderr.to_ascii_lowercase();
MARKERS.iter().any(|m| lowered.contains(m))
}
fn needs_operator(stderr: &str) -> bool {
const MARKERS: &[&str] = &[
"access denied",
"operator",
"must be run as root",
"permission denied",
"you must be root",
];
let lowered = stderr.to_ascii_lowercase();
MARKERS.iter().any(|m| lowered.contains(m))
}
fn is_not_found(stderr: &str) -> bool {
const MARKERS: &[&str] = &["no such", "not found", "does not exist", "unknown peer"];
let lowered = stderr.to_ascii_lowercase();
MARKERS.iter().any(|m| lowered.contains(m))
}
fn version_error(ctx: &ToolContext, meta: &ToolMeta) -> ToolError {
let needs = meta
.min_version
.map(str::to_owned)
.unwrap_or_else(|| crate::version::SUPPORTED_FLOOR.to_string());
let found = ctx
.cli_version
.map_or_else(|| "unknown".to_owned(), |v| v.to_string());
ToolError::unsupported_version(meta.name, &needs, &found)
}
pub fn version_permits(ctx: &ToolContext, meta: &ToolMeta) -> ToolResult<()> {
if satisfies(ctx.cli_version, meta.min_version) {
Ok(())
} else {
Err(version_error(ctx, meta))
}
}
pub async fn probe_version(backend: &dyn tailscale_cli::LocalBackend) -> Option<Version> {
let output = backend
.run(Invocation::read(["version"]))
.await
.ok()
.filter(Output::success)?;
Version::parse_cli_output(&output.stdout_str())
}
pub async fn probe_identity(backend: &dyn tailscale_cli::LocalBackend) -> SelfIdentity {
status_document(backend)
.await
.as_ref()
.map(identity_in)
.unwrap_or_default()
}
pub async fn probe_node(
backend: &dyn tailscale_cli::LocalBackend,
) -> (SelfIdentity, HashMap<IpAddr, String>) {
let Some(document) = status_document(backend).await else {
return (SelfIdentity::default(), HashMap::new());
};
(identity_in(&document), peer_names_in(&document))
}
pub(crate) async fn status_document(
backend: &dyn tailscale_cli::LocalBackend,
) -> Option<serde_json::Value> {
let output = backend
.run(Invocation::read(["status", "--json"]))
.await
.ok()
.filter(Output::success)?;
serde_json::from_str(&output.stdout_str()).ok()
}
fn identity_in(document: &serde_json::Value) -> SelfIdentity {
let node = &document["Self"];
SelfIdentity {
node_id: node["ID"].as_str().map(str::to_owned),
numeric_id: None,
addresses: node["TailscaleIPs"]
.as_array()
.map(|ips| {
ips.iter()
.filter_map(|ip| ip.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default(),
dns_name: node["DNSName"].as_str().map(str::to_owned),
}
}
fn peer_names_in(document: &serde_json::Value) -> HashMap<IpAddr, String> {
let mut named = HashMap::new();
let peers = document["Peer"]
.as_object()
.into_iter()
.flat_map(|by_key| by_key.values());
for node in std::iter::once(&document["Self"]).chain(peers) {
let Some(name) = node["DNSName"].as_str() else {
continue;
};
let name = name.trim_end_matches('.');
for address in node["TailscaleIPs"].as_array().into_iter().flatten() {
if let Some(address) = address.as_str().and_then(|a| a.parse().ok()) {
named.insert(address, name.to_owned());
}
}
}
named
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::context::{Identity, PathPolicy};
use crate::error::{ErrorCode, Redactor};
use crate::meta::{Tier, ToolMeta, Toolset};
use crate::testing::StubBackend;
fn meta(min_version: Option<&'static str>) -> ToolMeta {
ToolMeta {
name: "tailscale_service_list",
toolset: Toolset::LocalStatus,
tier: Tier::Read,
summary: "",
self_severing: false,
severs_local_node: false,
requires_confirmation: false,
idempotent: true,
varying_tier: false,
min_version,
platforms: None,
}
}
fn context(backend: StubBackend, cli_version: Option<Version>) -> ToolContext {
ToolContext {
local: Arc::new(backend),
tailnet: None,
redactor: Redactor::default(),
max_result_bytes: 1 << 20,
identity: Identity::default(),
cli_version,
paths: PathPolicy::default(),
devices: Default::default(),
max_tier: crate::meta::Tier::Destructive,
}
}
#[tokio::test]
async fn a_clean_exit_is_not_an_error() {
let ctx = context(StubBackend::ok("1.102.2\n"), None);
let text = run_text(&ctx, &meta(None), Invocation::read(["version"]))
.await
.expect("should succeed");
assert_eq!(text.trim(), "1.102.2");
}
#[tokio::test]
async fn an_unknown_subcommand_reports_the_minimum_version() {
let ctx = context(
StubBackend::failure(1, "tailscale service: unknown subcommand \"list\"\n"),
Some(Version::new(1, 78, 0)),
);
let err = run(
&ctx,
&meta(Some("1.94")),
Invocation::read(["service", "list"]),
)
.await
.expect_err("should fail");
assert_eq!(err.code, ErrorCode::UnsupportedVersion);
assert!(err.message.contains("1.94"), "{}", err.message);
assert!(err.message.contains("1.78.0"), "{}", err.message);
}
#[tokio::test]
async fn an_unknown_flag_reports_the_minimum_version() {
let ctx = context(
StubBackend::failure(1, "flag provided but not defined: -report-posture\n"),
None,
);
let err = run(&ctx, &meta(Some("1.58")), Invocation::read(["set"]))
.await
.expect_err("should fail");
assert_eq!(err.code, ErrorCode::UnsupportedVersion);
assert!(err.message.contains("1.58"), "{}", err.message);
}
#[tokio::test]
async fn a_tool_without_a_minimum_falls_back_to_the_floor() {
let ctx = context(StubBackend::failure(1, "unknown subcommand\n"), None);
let err = run(&ctx, &meta(None), Invocation::read(["nonsense"]))
.await
.expect_err("should fail");
assert_eq!(err.code, ErrorCode::UnsupportedVersion);
assert!(
err.message
.contains(&crate::version::SUPPORTED_FLOOR.to_string()),
"{}",
err.message
);
}
#[tokio::test]
async fn a_known_minimum_is_checked_before_the_command_runs() {
let ctx = context(StubBackend::ok(""), Some(Version::new(1, 78, 0)));
let err = version_permits(&ctx, &meta(Some("1.94"))).expect_err("should refuse");
assert_eq!(err.code, ErrorCode::UnsupportedVersion);
version_permits(&ctx, &meta(Some("1.72"))).expect("older requirement is met");
version_permits(&ctx, &meta(None)).expect("no requirement is always met");
}
#[tokio::test]
async fn a_permission_refusal_is_reported_as_needing_an_operator() {
let ctx = context(
StubBackend::failure(
1,
"Access denied: this operation requires the operator to be set\n",
),
None,
);
let err = run(&ctx, &meta(None), Invocation::read(["up"]))
.await
.expect_err("should fail");
assert_eq!(err.code, ErrorCode::NeedsOperator);
assert!(
err.hint.is_some(),
"an operator error should say what to do"
);
}
#[tokio::test]
async fn a_missing_target_is_reported_as_not_found() {
let ctx = context(StubBackend::failure(1, "no such peer: laptop\n"), None);
let err = run(&ctx, &meta(None), Invocation::read(["ping", "laptop"]))
.await
.expect_err("should fail");
assert_eq!(err.code, ErrorCode::NotFound);
}
#[tokio::test]
async fn anything_else_is_a_plain_command_failure() {
let ctx = context(StubBackend::failure(2, "something went wrong\n"), None);
let err = run(&ctx, &meta(None), Invocation::read(["status"]))
.await
.expect_err("should fail");
assert_eq!(err.code, ErrorCode::CliFailed);
assert_eq!(err.exit_code, Some(2));
assert_eq!(err.stderr.as_deref(), Some("something went wrong"));
}
#[tokio::test]
async fn a_missing_binary_disables_the_surface_rather_than_failing_the_command() {
let ctx = context(StubBackend::missing(), None);
let err = run(&ctx, &meta(None), Invocation::read(["status"]))
.await
.expect_err("should fail");
assert_eq!(err.code, ErrorCode::BackendUnavailable);
}
#[tokio::test]
async fn a_secret_in_the_error_stream_does_not_reach_the_caller() {
let ctx = context(
StubBackend::failure(1, "bad key tskey-auth-example1CNTRL-secretpart\n"),
None,
);
let err = run(&ctx, &meta(None), Invocation::read(["up"]))
.await
.expect_err("should fail");
assert!(
!err.stderr
.as_deref()
.unwrap_or_default()
.contains("secretpart"),
"{err:?}"
);
}
#[tokio::test]
async fn the_version_probe_reads_the_first_line() {
let backend = StubBackend::ok("1.102.2\n go version: go1.24.1\n");
assert_eq!(probe_version(&backend).await, Some(Version::new(1, 102, 2)));
}
#[tokio::test]
async fn the_version_probe_gives_up_quietly() {
assert_eq!(probe_version(&StubBackend::missing()).await, None);
assert_eq!(probe_version(&StubBackend::failure(1, "no")).await, None);
assert_eq!(probe_version(&StubBackend::ok("not a version")).await, None);
}
#[tokio::test]
async fn the_status_probe_names_this_node_and_its_peers() {
let backend = StubBackend::missing().on(
["status", "--json"],
tailscale_cli::stub::Reply::ok(
serde_json::json!({
"Self": {
"ID": "n1111111CNTRL",
"DNSName": "workstation.example-tailnet.ts.net.",
"TailscaleIPs": ["100.64.0.1", "fd7a:115c:a1e0::1"],
},
"Peer": {
"nodekey:2222": {
"DNSName": "laptop.example-tailnet.ts.net.",
"TailscaleIPs": ["100.64.0.2"],
},
"nodekey:3333": {"DNSName": "ghost.example-tailnet.ts.net."},
},
})
.to_string(),
),
);
let (identity, peers) = probe_node(&backend).await;
assert!(identity.matches("n1111111CNTRL"));
assert!(identity.matches("workstation"));
assert_eq!(
peers.get(&"100.64.0.2".parse::<IpAddr>().expect("an address")),
Some(&"laptop.example-tailnet.ts.net".to_owned()),
"a peer is named by the address a request would arrive from"
);
assert_eq!(
peers.get(&"100.64.0.1".parse::<IpAddr>().expect("an address")),
Some(&"workstation.example-tailnet.ts.net".to_owned()),
"and so is this node, which can reach its own HTTP transport"
);
assert_eq!(
peers.len(),
3,
"the trailing dot is dropped, the ghost has no address"
);
assert_eq!(
backend.calls().len(),
1,
"one reading of status, not one per answer wanted"
);
}
}