Skip to main content

tailscale_mcp/
cli.rs

1//! Running a local command and reading its failure.
2//!
3//! The interesting part is the reading. `tailscale` reports everything through
4//! a non-zero exit code and a line of English on standard error, so this is
5//! where that line becomes one of the error codes an agent can act on. Getting
6//! it wrong is not cosmetic: `needs_operator` tells the caller to run one
7//! command and try again, while `unsupported_version` tells it to stop asking.
8
9use std::collections::HashMap;
10use std::net::IpAddr;
11
12use tailscale_cli::{ExecError, Invocation, Output};
13
14use crate::context::{SelfIdentity, ToolContext};
15use crate::error::{ToolError, ToolResult};
16use crate::meta::ToolMeta;
17use crate::version::{Version, satisfies};
18
19/// Run a command, and turn anything other than a clean exit into a tool error.
20pub async fn run(ctx: &ToolContext, meta: &ToolMeta, invocation: Invocation) -> ToolResult<Output> {
21    let display = displayed(ctx, &invocation);
22    let output = ctx
23        .local
24        .run(invocation)
25        .await
26        .map_err(|e| exec_error(ctx, &display, e))?;
27    if output.success() {
28        return Ok(output);
29    }
30    Err(command_failure(ctx, meta, &display, &output))
31}
32
33/// Run a command whose refusal may be an ordinary answer.
34///
35/// Several `tailscale` commands report an unremarkable fact about the tailnet
36/// through a non-zero exit: `exit-node list` when there are no exit nodes,
37/// `routecheck` when no report has been produced yet, `wait` when the timeout
38/// passed, `status` when the backend is not running. Reporting those as tool
39/// failures would tell a caller that its call went wrong when what it learnt is
40/// exactly what it asked for.
41///
42/// What stays a failure is everything that is not an answer about the tailnet:
43/// a binary that is not there, a subcommand it does not know, a refusal to talk
44/// to us at all. The caller reads [`Output::success`] for the rest.
45pub async fn run_tolerant(
46    ctx: &ToolContext,
47    meta: &ToolMeta,
48    invocation: Invocation,
49) -> ToolResult<Output> {
50    let display = displayed(ctx, &invocation);
51    let output = ctx
52        .local
53        .run(invocation)
54        .await
55        .map_err(|e| exec_error(ctx, &display, e))?;
56    if output.success() {
57        return Ok(output);
58    }
59    let stderr = ctx.redactor.apply(&output.stderr);
60    if is_unrecognised(&stderr) {
61        return Err(version_error(ctx, meta));
62    }
63    if needs_operator(&stderr) {
64        return Err(ToolError::needs_operator(&stderr));
65    }
66    // Deliberately not `is_not_found`: "no exit nodes found" is the answer
67    // these commands exist to give.
68    Ok(output)
69}
70
71/// Run a command and hand back its standard output as text, failures aside.
72pub async fn run_text(
73    ctx: &ToolContext,
74    meta: &ToolMeta,
75    invocation: Invocation,
76) -> ToolResult<String> {
77    let output = run(ctx, meta, invocation).await?;
78    Ok(output.stdout_str().into_owned())
79}
80
81/// The command line as it may be shown: what ran, with any secret in it gone.
82///
83/// [`Invocation::display`] is the argument list verbatim, which every typed
84/// tool could show as it stands, because the server assembled those arguments
85/// itself and knows an auth key only ever reaches the CLI through a file. The
86/// passthrough has no such assurance: its arguments are the caller's, so a
87/// command line that turns into an error message, a log line or a report goes
88/// through here first. It is `pub(crate)` for the last of those: the
89/// passthrough puts the command in what it answers with, and a second spelling
90/// of this expression is a second place to forget it.
91pub(crate) fn displayed(ctx: &ToolContext, invocation: &Invocation) -> String {
92    ctx.redactor.apply(&invocation.display()).into_owned()
93}
94
95/// Something went wrong before the command produced a result.
96///
97/// Every message here goes through the session's redactor, not just the
98/// shape-based pass [`ToolError::new`] applies for itself. `ExecError::Io`
99/// names the command it was talking to, and since the passthrough that command
100/// line is the caller's; the rest are redacted too so that the next variant
101/// added does not have to be judged for whether it carries one.
102fn exec_error(ctx: &ToolContext, display: &str, error: ExecError) -> ToolError {
103    let told = |error: &ExecError| ctx.redactor.apply(&error.to_string()).into_owned();
104    match error {
105        ExecError::BinaryNotFound { .. }
106        | ExecError::BinaryNotExecutable { .. }
107        | ExecError::Spawn { .. } => {
108            ToolError::backend_unavailable("the local surface", &told(&error))
109        }
110        ExecError::Timeout {
111            timeout, printed, ..
112        } => ToolError::timeout(display, timeout.as_secs(), &ctx.redactor.apply(&printed)),
113        ExecError::Io { .. } | ExecError::SecretFile(_) => {
114            ToolError::new(crate::error::ErrorCode::CliFailed, told(&error))
115        }
116    }
117}
118
119/// The command ran and refused. Work out what kind of refusal it was.
120pub fn command_failure(
121    ctx: &ToolContext,
122    meta: &ToolMeta,
123    display: &str,
124    output: &Output,
125) -> ToolError {
126    let stderr = ctx.redactor.apply(&output.stderr);
127    if is_unrecognised(&stderr) {
128        return version_error(ctx, meta);
129    }
130    if needs_operator(&stderr) {
131        return ToolError::needs_operator(&stderr);
132    }
133    if is_not_found(&stderr) {
134        return ToolError::not_found(stderr.trim());
135    }
136    ToolError::cli_failed(display, output.exit_code, &stderr)
137}
138
139/// The binary does not know this subcommand or flag.
140///
141/// Matched on Go's `flag` package wording and the CLI's own dispatch, both of
142/// which are stable across the releases this server supports.
143fn is_unrecognised(stderr: &str) -> bool {
144    const MARKERS: &[&str] = &[
145        "flag provided but not defined",
146        "unknown flag",
147        "unknown subcommand",
148        "unknown command",
149        "is not a tailscale command",
150        "unrecognized command",
151    ];
152    let lowered = stderr.to_ascii_lowercase();
153    MARKERS.iter().any(|m| lowered.contains(m))
154}
155
156/// The command exists but this user may not run it.
157fn needs_operator(stderr: &str) -> bool {
158    const MARKERS: &[&str] = &[
159        "access denied",
160        "operator",
161        "must be run as root",
162        "permission denied",
163        "you must be root",
164    ];
165    let lowered = stderr.to_ascii_lowercase();
166    MARKERS.iter().any(|m| lowered.contains(m))
167}
168
169/// The command ran but its target is not there.
170fn is_not_found(stderr: &str) -> bool {
171    const MARKERS: &[&str] = &["no such", "not found", "does not exist", "unknown peer"];
172    let lowered = stderr.to_ascii_lowercase();
173    MARKERS.iter().any(|m| lowered.contains(m))
174}
175
176/// Report an unrecognised command as a version problem.
177///
178/// The minimum comes from the tool's own row when it has one. When it does not
179/// — the command predates our floor and should have been there — the floor is
180/// the honest answer: something older than anything we model is running.
181fn version_error(ctx: &ToolContext, meta: &ToolMeta) -> ToolError {
182    let needs = meta
183        .min_version
184        .map(str::to_owned)
185        .unwrap_or_else(|| crate::version::SUPPORTED_FLOOR.to_string());
186    let found = ctx
187        .cli_version
188        .map_or_else(|| "unknown".to_owned(), |v| v.to_string());
189    ToolError::unsupported_version(meta.name, &needs, &found)
190}
191
192/// Whether a tool's stated minimum is met by the CLI we found.
193///
194/// Checked before spawning, so that a tool with a known minimum reports the
195/// version code with its own number rather than whatever the binary says.
196pub fn version_permits(ctx: &ToolContext, meta: &ToolMeta) -> ToolResult<()> {
197    if satisfies(ctx.cli_version, meta.min_version) {
198        Ok(())
199    } else {
200        Err(version_error(ctx, meta))
201    }
202}
203
204/// Read the version out of `tailscale version`.
205///
206/// A failure here is not an error: the probe runs at startup, and a server that
207/// refused to start because it could not read a version string would be worse
208/// than one that runs without knowing it.
209pub async fn probe_version(backend: &dyn tailscale_cli::LocalBackend) -> Option<Version> {
210    let output = backend
211        .run(Invocation::read(["version"]))
212        .await
213        .ok()
214        .filter(Output::success)?;
215    Version::parse_cli_output(&output.stdout_str())
216}
217
218/// Read who this node is from `tailscale status --json`.
219///
220/// Only the handful of fields that name this node, because the point is to
221/// recognise a control-plane operation aimed at ourselves (ticket 21) rather
222/// than to model the status document — ticket 08 does that properly.
223///
224/// A failure gives an identity that matches nothing, which is the safe way
225/// round: an operation we cannot prove is aimed at ourselves is treated as an
226/// ordinary one, and the operator sees the same confirmation rules as anyone
227/// managing another node.
228pub async fn probe_identity(backend: &dyn tailscale_cli::LocalBackend) -> SelfIdentity {
229    status_document(backend)
230        .await
231        .as_ref()
232        .map(identity_in)
233        .unwrap_or_default()
234}
235
236/// Who this node is and what it calls its peers, from one reading of status.
237///
238/// Both at once because both come out of the same document, and running
239/// `tailscale status` twice at startup to parse the same JSON two ways is work
240/// nobody asked for.
241pub async fn probe_node(
242    backend: &dyn tailscale_cli::LocalBackend,
243) -> (SelfIdentity, HashMap<IpAddr, String>) {
244    let Some(document) = status_document(backend).await else {
245        return (SelfIdentity::default(), HashMap::new());
246    };
247    (identity_in(&document), peer_names_in(&document))
248}
249
250/// `tailscale status --json`, parsed, or nothing if it could not be had.
251pub(crate) async fn status_document(
252    backend: &dyn tailscale_cli::LocalBackend,
253) -> Option<serde_json::Value> {
254    let output = backend
255        .run(Invocation::read(["status", "--json"]))
256        .await
257        .ok()
258        .filter(Output::success)?;
259    serde_json::from_str(&output.stdout_str()).ok()
260}
261
262fn identity_in(document: &serde_json::Value) -> SelfIdentity {
263    let node = &document["Self"];
264    SelfIdentity {
265        node_id: node["ID"].as_str().map(str::to_owned),
266        // Status cannot supply this: the numeric id is the control plane's own
267        // name for the device and is never sent to the node. Filled in from
268        // the control plane by `ToolContext::names_us`.
269        numeric_id: None,
270        addresses: node["TailscaleIPs"]
271            .as_array()
272            .map(|ips| {
273                ips.iter()
274                    .filter_map(|ip| ip.as_str().map(str::to_owned))
275                    .collect()
276            })
277            .unwrap_or_default(),
278        dns_name: node["DNSName"].as_str().map(str::to_owned),
279    }
280}
281
282/// Every node this node can name, by address.
283///
284/// For the HTTP transport's log line: a request arriving from `100.64.0.2` is
285/// more useful in a log as the node it came from, and the local node already
286/// knows the mapping. An address this node has never heard of stays an
287/// address (ticket 23).
288fn peer_names_in(document: &serde_json::Value) -> HashMap<IpAddr, String> {
289    let mut named = HashMap::new();
290    let peers = document["Peer"]
291        .as_object()
292        .into_iter()
293        .flat_map(|by_key| by_key.values());
294    for node in std::iter::once(&document["Self"]).chain(peers) {
295        let Some(name) = node["DNSName"].as_str() else {
296            continue;
297        };
298        let name = name.trim_end_matches('.');
299        for address in node["TailscaleIPs"].as_array().into_iter().flatten() {
300            if let Some(address) = address.as_str().and_then(|a| a.parse().ok()) {
301                named.insert(address, name.to_owned());
302            }
303        }
304    }
305    named
306}
307
308#[cfg(test)]
309mod tests {
310    use std::sync::Arc;
311
312    use super::*;
313    use crate::context::{Identity, PathPolicy};
314    use crate::error::{ErrorCode, Redactor};
315    use crate::meta::{Tier, ToolMeta, Toolset};
316    use crate::testing::StubBackend;
317
318    fn meta(min_version: Option<&'static str>) -> ToolMeta {
319        ToolMeta {
320            name: "tailscale_service_list",
321            toolset: Toolset::LocalStatus,
322            tier: Tier::Read,
323            summary: "",
324            self_severing: false,
325            severs_local_node: false,
326            requires_confirmation: false,
327            idempotent: true,
328            varying_tier: false,
329            min_version,
330            platforms: None,
331        }
332    }
333
334    fn context(backend: StubBackend, cli_version: Option<Version>) -> ToolContext {
335        ToolContext {
336            local: Arc::new(backend),
337            tailnet: None,
338            redactor: Redactor::default(),
339            max_result_bytes: 1 << 20,
340            identity: Identity::default(),
341            cli_version,
342            paths: PathPolicy::default(),
343            devices: Default::default(),
344            max_tier: crate::meta::Tier::Destructive,
345        }
346    }
347
348    #[tokio::test]
349    async fn a_clean_exit_is_not_an_error() {
350        let ctx = context(StubBackend::ok("1.102.2\n"), None);
351        let text = run_text(&ctx, &meta(None), Invocation::read(["version"]))
352            .await
353            .expect("should succeed");
354        assert_eq!(text.trim(), "1.102.2");
355    }
356
357    #[tokio::test]
358    async fn an_unknown_subcommand_reports_the_minimum_version() {
359        let ctx = context(
360            StubBackend::failure(1, "tailscale service: unknown subcommand \"list\"\n"),
361            Some(Version::new(1, 78, 0)),
362        );
363        let err = run(
364            &ctx,
365            &meta(Some("1.94")),
366            Invocation::read(["service", "list"]),
367        )
368        .await
369        .expect_err("should fail");
370        assert_eq!(err.code, ErrorCode::UnsupportedVersion);
371        assert!(err.message.contains("1.94"), "{}", err.message);
372        assert!(err.message.contains("1.78.0"), "{}", err.message);
373    }
374
375    #[tokio::test]
376    async fn an_unknown_flag_reports_the_minimum_version() {
377        let ctx = context(
378            StubBackend::failure(1, "flag provided but not defined: -report-posture\n"),
379            None,
380        );
381        let err = run(&ctx, &meta(Some("1.58")), Invocation::read(["set"]))
382            .await
383            .expect_err("should fail");
384        assert_eq!(err.code, ErrorCode::UnsupportedVersion);
385        assert!(err.message.contains("1.58"), "{}", err.message);
386    }
387
388    #[tokio::test]
389    async fn a_tool_without_a_minimum_falls_back_to_the_floor() {
390        let ctx = context(StubBackend::failure(1, "unknown subcommand\n"), None);
391        let err = run(&ctx, &meta(None), Invocation::read(["nonsense"]))
392            .await
393            .expect_err("should fail");
394        assert_eq!(err.code, ErrorCode::UnsupportedVersion);
395        assert!(
396            err.message
397                .contains(&crate::version::SUPPORTED_FLOOR.to_string()),
398            "{}",
399            err.message
400        );
401    }
402
403    #[tokio::test]
404    async fn a_known_minimum_is_checked_before_the_command_runs() {
405        let ctx = context(StubBackend::ok(""), Some(Version::new(1, 78, 0)));
406        let err = version_permits(&ctx, &meta(Some("1.94"))).expect_err("should refuse");
407        assert_eq!(err.code, ErrorCode::UnsupportedVersion);
408        version_permits(&ctx, &meta(Some("1.72"))).expect("older requirement is met");
409        version_permits(&ctx, &meta(None)).expect("no requirement is always met");
410    }
411
412    #[tokio::test]
413    async fn a_permission_refusal_is_reported_as_needing_an_operator() {
414        let ctx = context(
415            StubBackend::failure(
416                1,
417                "Access denied: this operation requires the operator to be set\n",
418            ),
419            None,
420        );
421        let err = run(&ctx, &meta(None), Invocation::read(["up"]))
422            .await
423            .expect_err("should fail");
424        assert_eq!(err.code, ErrorCode::NeedsOperator);
425        assert!(
426            err.hint.is_some(),
427            "an operator error should say what to do"
428        );
429    }
430
431    #[tokio::test]
432    async fn a_missing_target_is_reported_as_not_found() {
433        let ctx = context(StubBackend::failure(1, "no such peer: laptop\n"), None);
434        let err = run(&ctx, &meta(None), Invocation::read(["ping", "laptop"]))
435            .await
436            .expect_err("should fail");
437        assert_eq!(err.code, ErrorCode::NotFound);
438    }
439
440    #[tokio::test]
441    async fn anything_else_is_a_plain_command_failure() {
442        let ctx = context(StubBackend::failure(2, "something went wrong\n"), None);
443        let err = run(&ctx, &meta(None), Invocation::read(["status"]))
444            .await
445            .expect_err("should fail");
446        assert_eq!(err.code, ErrorCode::CliFailed);
447        assert_eq!(err.exit_code, Some(2));
448        assert_eq!(err.stderr.as_deref(), Some("something went wrong"));
449    }
450
451    #[tokio::test]
452    async fn a_missing_binary_disables_the_surface_rather_than_failing_the_command() {
453        let ctx = context(StubBackend::missing(), None);
454        let err = run(&ctx, &meta(None), Invocation::read(["status"]))
455            .await
456            .expect_err("should fail");
457        assert_eq!(err.code, ErrorCode::BackendUnavailable);
458    }
459
460    #[tokio::test]
461    async fn a_secret_in_the_error_stream_does_not_reach_the_caller() {
462        let ctx = context(
463            StubBackend::failure(1, "bad key tskey-auth-example1CNTRL-secretpart\n"),
464            None,
465        );
466        let err = run(&ctx, &meta(None), Invocation::read(["up"]))
467            .await
468            .expect_err("should fail");
469        assert!(
470            !err.stderr
471                .as_deref()
472                .unwrap_or_default()
473                .contains("secretpart"),
474            "{err:?}"
475        );
476    }
477
478    #[tokio::test]
479    async fn the_version_probe_reads_the_first_line() {
480        let backend = StubBackend::ok("1.102.2\n  go version: go1.24.1\n");
481        assert_eq!(probe_version(&backend).await, Some(Version::new(1, 102, 2)));
482    }
483
484    #[tokio::test]
485    async fn the_version_probe_gives_up_quietly() {
486        assert_eq!(probe_version(&StubBackend::missing()).await, None);
487        assert_eq!(probe_version(&StubBackend::failure(1, "no")).await, None);
488        assert_eq!(probe_version(&StubBackend::ok("not a version")).await, None);
489    }
490
491    /// One reading of status, two answers out of it.
492    #[tokio::test]
493    async fn the_status_probe_names_this_node_and_its_peers() {
494        let backend = StubBackend::missing().on(
495            ["status", "--json"],
496            tailscale_cli::stub::Reply::ok(
497                serde_json::json!({
498                    "Self": {
499                        "ID": "n1111111CNTRL",
500                        "DNSName": "workstation.example-tailnet.ts.net.",
501                        "TailscaleIPs": ["100.64.0.1", "fd7a:115c:a1e0::1"],
502                    },
503                    "Peer": {
504                        "nodekey:2222": {
505                            "DNSName": "laptop.example-tailnet.ts.net.",
506                            "TailscaleIPs": ["100.64.0.2"],
507                        },
508                        // No addresses, so nothing to key it by.
509                        "nodekey:3333": {"DNSName": "ghost.example-tailnet.ts.net."},
510                    },
511                })
512                .to_string(),
513            ),
514        );
515
516        let (identity, peers) = probe_node(&backend).await;
517        assert!(identity.matches("n1111111CNTRL"));
518        assert!(identity.matches("workstation"));
519        assert_eq!(
520            peers.get(&"100.64.0.2".parse::<IpAddr>().expect("an address")),
521            Some(&"laptop.example-tailnet.ts.net".to_owned()),
522            "a peer is named by the address a request would arrive from"
523        );
524        assert_eq!(
525            peers.get(&"100.64.0.1".parse::<IpAddr>().expect("an address")),
526            Some(&"workstation.example-tailnet.ts.net".to_owned()),
527            "and so is this node, which can reach its own HTTP transport"
528        );
529        assert_eq!(
530            peers.len(),
531            3,
532            "the trailing dot is dropped, the ghost has no address"
533        );
534
535        assert_eq!(
536            backend.calls().len(),
537            1,
538            "one reading of status, not one per answer wanted"
539        );
540    }
541}