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 an_unknown_subcommand_reports_the_minimum_version() {
350        let ctx = context(
351            StubBackend::failure(1, "tailscale service: unknown subcommand \"list\"\n"),
352            Some(Version::new(1, 78, 0)),
353        );
354        let err = run(
355            &ctx,
356            &meta(Some("1.94")),
357            Invocation::read(["service", "list"]),
358        )
359        .await
360        .expect_err("should fail");
361        assert_eq!(err.code, ErrorCode::UnsupportedVersion);
362        assert!(err.message.contains("1.94"), "{}", err.message);
363        assert!(err.message.contains("1.78.0"), "{}", err.message);
364    }
365
366    #[tokio::test]
367    async fn an_unknown_flag_reports_the_minimum_version() {
368        let ctx = context(
369            StubBackend::failure(1, "flag provided but not defined: -report-posture\n"),
370            None,
371        );
372        let err = run(&ctx, &meta(Some("1.58")), Invocation::read(["set"]))
373            .await
374            .expect_err("should fail");
375        assert_eq!(err.code, ErrorCode::UnsupportedVersion);
376        assert!(err.message.contains("1.58"), "{}", err.message);
377    }
378
379    #[tokio::test]
380    async fn a_tool_without_a_minimum_falls_back_to_the_floor() {
381        let ctx = context(StubBackend::failure(1, "unknown subcommand\n"), None);
382        let err = run(&ctx, &meta(None), Invocation::read(["nonsense"]))
383            .await
384            .expect_err("should fail");
385        assert_eq!(err.code, ErrorCode::UnsupportedVersion);
386        assert!(
387            err.message
388                .contains(&crate::version::SUPPORTED_FLOOR.to_string()),
389            "{}",
390            err.message
391        );
392    }
393
394    #[tokio::test]
395    async fn a_peer_named_for_an_operator_is_still_not_found() {
396        let ctx = context(
397            StubBackend::failure(1, "no such peer: operator-laptop\n"),
398            None,
399        );
400        let err = run(&ctx, &meta(None), Invocation::read(["ping"]))
401            .await
402            .expect_err("should fail");
403        assert_eq!(err.code, ErrorCode::NotFound, "{err:?}");
404    }
405
406    #[tokio::test]
407    async fn a_secret_in_the_error_stream_does_not_reach_the_caller() {
408        let ctx = context(
409            StubBackend::failure(1, "bad key tskey-auth-example1CNTRL-secretpart\n"),
410            None,
411        );
412        let err = run(&ctx, &meta(None), Invocation::read(["up"]))
413            .await
414            .expect_err("should fail");
415        assert!(
416            !err.stderr
417                .as_deref()
418                .unwrap_or_default()
419                .contains("secretpart"),
420            "{err:?}"
421        );
422    }
423
424    #[test]
425    fn a_broken_pipe_or_an_unwritable_secret_file_is_a_cli_failure() {
426        let ctx = context(StubBackend::ok(""), None);
427        for error in [
428            ExecError::Io {
429                command: "tailscale up".to_owned(),
430                source: std::io::Error::other("broken pipe"),
431            },
432            ExecError::SecretFile(std::io::Error::other("no space left on device")),
433        ] {
434            let told = error.to_string();
435            assert_eq!(
436                exec_error(&ctx, "tailscale up", error).code,
437                ErrorCode::CliFailed,
438                "{told}"
439            );
440        }
441    }
442
443    /// One reading of status, two answers out of it.
444    #[tokio::test]
445    async fn the_status_probe_names_this_node_and_its_peers() {
446        let backend = StubBackend::missing().on(
447            ["status", "--json"],
448            tailscale_cli::stub::Reply::ok(
449                serde_json::json!({
450                    "Self": {
451                        "ID": "n1111111CNTRL",
452                        "DNSName": "workstation.example-tailnet.ts.net.",
453                        "TailscaleIPs": ["100.64.0.1", "fd7a:115c:a1e0::1"],
454                    },
455                    "Peer": {
456                        "nodekey:2222": {
457                            "DNSName": "laptop.example-tailnet.ts.net.",
458                            "TailscaleIPs": ["100.64.0.2"],
459                        },
460                        // No addresses, so nothing to key it by.
461                        "nodekey:3333": {"DNSName": "ghost.example-tailnet.ts.net."},
462                    },
463                })
464                .to_string(),
465            ),
466        );
467
468        let (identity, peers) = probe_node(&backend).await;
469        assert!(identity.matches("n1111111CNTRL"));
470        assert!(identity.matches("workstation"));
471        assert_eq!(
472            peers.get(&"100.64.0.2".parse::<IpAddr>().expect("an address")),
473            Some(&"laptop.example-tailnet.ts.net".to_owned()),
474            "a peer is named by the address a request would arrive from"
475        );
476        assert_eq!(
477            peers.get(&"100.64.0.1".parse::<IpAddr>().expect("an address")),
478            Some(&"workstation.example-tailnet.ts.net".to_owned()),
479            "and so is this node, which can reach its own HTTP transport"
480        );
481        assert_eq!(
482            peers.len(),
483            3,
484            "the trailing dot is dropped, the ghost has no address"
485        );
486
487        assert_eq!(
488            backend.calls().len(),
489            1,
490            "one reading of status, not one per answer wanted"
491        );
492    }
493}