Skip to main content

tailscale_mcp/tools/
local_status.rs

1//! Reading the state of the local node.
2//!
3//! Every read-only local command lives here, whichever part of the command tree
4//! it comes from: `lock status` sits beside `status` because a toolset is a
5//! group of things an operator is willing to let a model do, and reading the
6//! tailnet-lock state is the same willingness as reading the peer list. The
7//! grouping is by risk, not by the shape of the CLI (DECISIONS Q14).
8//!
9//! Two rules run through the module. Where the CLI prints a JSON document, that
10//! document is forwarded unmodified — the control plane's schema is better
11//! documentation than any re-modelling of it, and it changes on Tailscale's
12//! schedule rather than ours. Where the CLI prints text, there is a parser, and
13//! the parser has a test against a recorded sample.
14
15use std::collections::BTreeMap;
16use std::time::Duration;
17
18use rmcp::schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20use serde_json::{Value, json};
21use tailscale_cli::Invocation;
22
23use crate::cli;
24use crate::context::ToolContext;
25use crate::error::ToolResult;
26use crate::meta::ToolMeta;
27use crate::tools::common::{bounded_wait, document, flag, lines, note, object, report};
28use crate::version::{SUPPORTED_FLOOR, Version};
29
30crate::tools! {
31    /// Report the state of this node and of every peer it can see: addresses,
32    /// operating systems, connection paths, transfer counters and health
33    /// warnings. The whole `tailscale status --json` document, unmodified.
34    tailscale_status => StatusParams, status,
35        toolset: LocalStatus, tier: Read, idempotent: true;
36
37    /// List this node's Tailscale addresses, or resolve a peer or service name
38    /// to its addresses.
39    tailscale_ip => IpParams, ip,
40        toolset: LocalStatus, tier: Read, idempotent: true;
41
42    /// Probe the local network and the DERP relays: whether UDP works, whether
43    /// the NAT is symmetric, which relay is nearest and how far away each one
44    /// is. Sends traffic, and takes a second or two.
45    tailscale_netcheck => NoParams, netcheck,
46        toolset: LocalStatus, tier: Read, idempotent: true;
47
48    /// Ping a peer at the Tailscale layer and report the path each reply took,
49    /// which is how you tell a direct connection from a relayed one.
50    tailscale_ping => PingParams, ping,
51        toolset: LocalStatus, tier: Read, idempotent: true;
52
53    /// Identify the machine and user behind a Tailscale address.
54    tailscale_whois => WhoisParams, whois,
55        toolset: LocalStatus, tier: Read, idempotent: true;
56
57    /// Identify this node: its machine record and the user it is logged in as.
58    tailscale_whoami => NoParams, whoami,
59        toolset: LocalStatus, tier: Read, idempotent: true, since: "1.90";
60
61    /// Report the version of the `tailscale` binary this server drives, and
62    /// whether it is new enough for everything this server models.
63    tailscale_version => NoParams, version,
64        toolset: LocalStatus, tier: Read, idempotent: true;
65
66    /// Print where the open-source licences of the components in this client
67    /// build are published.
68    tailscale_licenses => NoParams, licenses,
69        toolset: LocalStatus, tier: Read, idempotent: true;
70
71    /// Emit a diagnostic marker to Tailscale's log service and return it, so
72    /// that it can be quoted to Tailscale support. Uploads nothing beyond what
73    /// the client already logs.
74    tailscale_bugreport => BugreportParams, bugreport,
75        toolset: LocalStatus, tier: Read, idempotent: false;
76
77    /// List the routes this node has learnt as an app connector, and say
78    /// whether it is acting as one at all.
79    tailscale_appc_routes => AppcRoutesParams, appc_routes,
80        toolset: LocalStatus, tier: Read, idempotent: true, since: "1.90";
81
82    /// Report the experimental reachability check: which advertised routes this
83    /// node can reach and through which peer. Often has no report to give until
84    /// a probe has been asked for.
85    tailscale_routecheck => RoutecheckParams, routecheck,
86        toolset: LocalStatus, tier: Read, idempotent: true, since: "1.102";
87
88    /// Wait until the node's network interface and addresses are ready, up to a
89    /// bounded timeout. Returns immediately when they already are.
90    tailscale_wait => WaitParams, wait,
91        toolset: LocalStatus, tier: Read, idempotent: true, since: "1.90";
92
93    /// Report how DNS is resolving: whether MagicDNS is in use, which
94    /// nameservers the tailnet supplies, and what the operating system's own
95    /// resolver is configured with.
96    tailscale_dns_status => DnsStatusParams, dns_status,
97        toolset: LocalStatus, tier: Read, idempotent: true, since: "1.72";
98
99    /// Resolve a name through Tailscale's resolver and report the answer,
100    /// including which resolver answered.
101    tailscale_dns_query => DnsQueryParams, dns_query,
102        toolset: LocalStatus, tier: Read, idempotent: true, since: "1.72";
103
104    /// List the exit nodes available to this node, both those in the tailnet
105    /// and those from Mullvad, and say which one is selected.
106    tailscale_exit_node_list => ExitNodeListParams, exit_node_list,
107        toolset: LocalStatus, tier: Read, idempotent: true;
108
109    /// Ask the client which exit node it would pick, by latency and location.
110    tailscale_exit_node_suggest => NoParams, exit_node_suggest,
111        toolset: LocalStatus, tier: Read, idempotent: true, since: "1.66";
112
113    /// Report the client's user-facing metrics: routes advertised and approved,
114    /// home relay region, and bytes and packets by path.
115    tailscale_metrics_print => NoParams, metrics_print,
116        toolset: LocalStatus, tier: Read, idempotent: true, since: "1.78";
117
118    /// List the Tailscale Services this node hosts.
119    tailscale_service_list => NoParams, service_list,
120        toolset: LocalStatus, tier: Read, idempotent: true, since: "1.90";
121
122    /// List the system policies in force on this machine — the settings an MDM
123    /// profile or a local administrator has fixed — and where each came from.
124    tailscale_syspolicy_list => NoParams, syspolicy_list,
125        toolset: LocalStatus, tier: Read, idempotent: true, since: "1.72";
126
127    /// Report whether tailnet lock is enabled and what this node's
128    /// tailnet-lock key is.
129    tailscale_lock_status => NoParams, lock_status,
130        toolset: LocalStatus, tier: Read, idempotent: true;
131
132    /// List the tailnet-lock updates this node knows about, newest first.
133    tailscale_lock_log => LockLogParams, lock_log,
134        toolset: LocalStatus, tier: Read, idempotent: true;
135
136    /// Report what this node is currently serving on the tailnet: which ports
137    /// are listening and what each path forwards to.
138    tailscale_serve_status => NoParams, serve_status,
139        toolset: LocalStatus, tier: Read, idempotent: true;
140
141    /// Report what this node is currently exposing to the public internet
142    /// through Funnel.
143    tailscale_funnel_status => NoParams, funnel_status,
144        toolset: LocalStatus, tier: Read, idempotent: true;
145
146    /// Report the state of the macOS system extension the standalone client
147    /// runs its networking in.
148    tailscale_configure_sysext_status => NoParams, configure_sysext_status,
149        toolset: LocalStatus, tier: Read, idempotent: true, platforms: ["macos"];
150
151    /// List the Tailscale accounts stored on this machine and say which one is
152    /// currently active.
153    tailscale_switch_list => NoParams, switch_list,
154        toolset: LocalStatus, tier: Read, idempotent: true;
155}
156
157// ---------------------------------------------------------------------------
158// Shared shapes
159// ---------------------------------------------------------------------------
160
161/// A tool that takes nothing.
162#[derive(Debug, Deserialize, JsonSchema)]
163pub struct NoParams {}
164
165/// The serde default for a flag that is on unless the caller says otherwise.
166const fn yes() -> bool {
167    true
168}
169
170/// A document that is a list, under a field naming what the list holds.
171///
172/// `null` means the empty list: several of these commands print it when they
173/// have nothing, and a caller should not have to tell the two apart.
174async fn collection(
175    ctx: &ToolContext,
176    meta: &ToolMeta,
177    invocation: Invocation,
178    field: &str,
179) -> ToolResult<Value> {
180    let value = document(ctx, meta, invocation).await?;
181    let items = match value {
182        Value::Null => Value::Array(Vec::new()),
183        Value::Array(items) => Value::Array(items),
184        other => Value::Array(vec![other]),
185    };
186    Ok(json!({ field: items }))
187}
188
189// ---------------------------------------------------------------------------
190// status
191// ---------------------------------------------------------------------------
192
193#[derive(Debug, Deserialize, JsonSchema)]
194pub struct StatusParams {
195    /// Include the peer list. Turning it off makes the answer far smaller on a
196    /// large tailnet, at the cost of `Peer` coming back null.
197    #[serde(default = "yes")]
198    pub peers: bool,
199    /// Include this node's own entry.
200    #[serde(default = "yes")]
201    pub include_self: bool,
202    /// Only report peers with an active session.
203    #[serde(default)]
204    pub active: bool,
205}
206
207async fn status(ctx: &ToolContext, params: StatusParams) -> ToolResult<Value> {
208    object(
209        ctx,
210        &metas::tailscale_status,
211        Invocation::read([
212            "status".to_owned(),
213            "--json=true".to_owned(),
214            flag("peers", params.peers),
215            flag("self", params.include_self),
216            flag("active", params.active),
217        ]),
218    )
219    .await
220}
221
222// ---------------------------------------------------------------------------
223// ip
224// ---------------------------------------------------------------------------
225
226/// Which addresses to report.
227#[derive(Debug, Default, Clone, Copy, Deserialize, JsonSchema)]
228#[serde(rename_all = "snake_case")]
229pub enum AddressFamily {
230    /// Both families, in the order the client prints them.
231    #[default]
232    Any,
233    Ipv4,
234    Ipv6,
235}
236
237#[derive(Debug, Deserialize, JsonSchema)]
238pub struct IpParams {
239    /// A peer hostname, MagicDNS name, service name or address to resolve.
240    /// Omit it to report this node's own addresses.
241    #[serde(default)]
242    pub target: Option<String>,
243    #[serde(default)]
244    pub family: AddressFamily,
245}
246
247#[derive(Debug, Serialize, JsonSchema)]
248pub struct AddressReport {
249    /// What was asked about: the target, or this node.
250    pub target: Option<String>,
251    /// One entry per address the client printed, in its order.
252    pub addresses: Vec<String>,
253}
254
255async fn ip(ctx: &ToolContext, params: IpParams) -> ToolResult<Value> {
256    let mut args = vec!["ip".to_owned()];
257    match params.family {
258        AddressFamily::Any => {}
259        AddressFamily::Ipv4 => args.push("--4=true".to_owned()),
260        AddressFamily::Ipv6 => args.push("--6=true".to_owned()),
261    }
262    if let Some(target) = &params.target {
263        args.push(target.clone());
264    }
265    let text = cli::run_text(ctx, &metas::tailscale_ip, Invocation::read(args)).await?;
266    report(AddressReport {
267        target: params.target,
268        addresses: lines(&text).map(str::to_owned).collect(),
269    })
270}
271
272// ---------------------------------------------------------------------------
273// netcheck
274// ---------------------------------------------------------------------------
275
276async fn netcheck(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
277    object(
278        ctx,
279        &metas::tailscale_netcheck,
280        // `--format=json`, not `--json`: netcheck predates the flag the rest of
281        // the CLI settled on.
282        Invocation::read(["netcheck", "--format=json"]),
283    )
284    .await
285}
286
287// ---------------------------------------------------------------------------
288// ping
289// ---------------------------------------------------------------------------
290
291/// The most pings one call will send. The CLI's own default is 10 and its
292/// maximum is unbounded; a model that asks for a thousand should get a bounded
293/// call rather than a session that stops answering.
294const MAX_PING_COUNT: u32 = 20;
295/// The longest one call will wait for a single reply.
296const MAX_PING_TIMEOUT: u64 = 30;
297/// What a caller gets by asking for nothing.
298const DEFAULT_PING_COUNT: u32 = 5;
299const DEFAULT_PING_TIMEOUT: u64 = 5;
300
301const fn default_ping_count() -> u32 {
302    DEFAULT_PING_COUNT
303}
304const fn default_ping_timeout() -> u64 {
305    DEFAULT_PING_TIMEOUT
306}
307
308/// How to reach the peer.
309#[derive(Debug, Default, Clone, Copy, Deserialize, JsonSchema)]
310#[serde(rename_all = "snake_case")]
311pub enum PingMethod {
312    /// Tailscale's own discovery ping, which reports the path.
313    #[default]
314    Disco,
315    /// ICMP inside the tunnel.
316    Icmp,
317    /// Tailscale's in-tunnel message protocol.
318    Tsmp,
319    /// An HTTP request to the peer's peer API.
320    Peerapi,
321}
322
323#[derive(Debug, Deserialize, JsonSchema)]
324pub struct PingParams {
325    /// A peer hostname, MagicDNS name or Tailscale address.
326    pub target: String,
327    /// How many pings to send. Capped at 20.
328    #[serde(default = "default_ping_count")]
329    pub count: u32,
330    /// How long to wait for each reply, in seconds. Capped at 30.
331    #[serde(default = "default_ping_timeout")]
332    pub timeout_seconds: u64,
333    #[serde(default)]
334    pub method: PingMethod,
335    /// Stop as soon as the path becomes direct, rather than sending every ping.
336    #[serde(default = "yes")]
337    pub until_direct: bool,
338}
339
340/// One reply, as the client printed it.
341#[derive(Debug, Serialize, JsonSchema, PartialEq)]
342pub struct PingReply {
343    /// The peer that answered.
344    pub peer: String,
345    /// The address it answered from.
346    pub address: String,
347    /// The path the reply took: a relay region, or an address for a direct
348    /// connection.
349    pub via: String,
350    /// Round-trip time in milliseconds, when the client reported one.
351    pub latency_ms: Option<f64>,
352}
353
354impl PingReply {
355    /// Whether this reply came over a direct connection rather than a relay.
356    fn is_direct(&self) -> bool {
357        !self.via.starts_with("DERP")
358    }
359}
360
361#[derive(Debug, Serialize, JsonSchema)]
362pub struct PingReport {
363    pub target: String,
364    /// Whether anything answered at all.
365    pub reachable: bool,
366    /// Whether the last reply came over a direct connection.
367    pub direct: bool,
368    pub replies: Vec<PingReply>,
369    /// Everything the client printed, including lines that are not replies.
370    pub raw: String,
371}
372
373/// Read one `pong from …` line.
374fn parse_pong(line: &str) -> Option<PingReply> {
375    let rest = line.strip_prefix("pong from ")?;
376    let (peer, rest) = rest.split_once(" (")?;
377    let (address, rest) = rest.split_once(") via ")?;
378    let (via, latency) = rest.rsplit_once(" in ")?;
379    Some(PingReply {
380        peer: peer.to_owned(),
381        address: address.to_owned(),
382        via: via.to_owned(),
383        latency_ms: latency
384            .trim()
385            .strip_suffix("ms")
386            .and_then(|value| value.parse().ok()),
387    })
388}
389
390async fn ping(ctx: &ToolContext, params: PingParams) -> ToolResult<Value> {
391    let count = params.count.clamp(1, MAX_PING_COUNT);
392    let per_ping = params.timeout_seconds.clamp(1, MAX_PING_TIMEOUT);
393    let mut args = vec![
394        "ping".to_owned(),
395        format!("--c={count}"),
396        format!("--timeout={per_ping}s"),
397        flag("until-direct", params.until_direct),
398    ];
399    match params.method {
400        PingMethod::Disco => {}
401        PingMethod::Icmp => args.push("--icmp=true".to_owned()),
402        PingMethod::Tsmp => args.push("--tsmp=true".to_owned()),
403        PingMethod::Peerapi => args.push("--peerapi=true".to_owned()),
404    }
405    args.push(params.target.clone());
406
407    // The command bounds itself, but only if it behaves. The wall-clock bound
408    // is what makes "a bounded call always returns" true even when it does not.
409    let budget = Duration::from_secs(u64::from(count) * per_ping + 5);
410    let text = cli::run_text(
411        ctx,
412        &metas::tailscale_ping,
413        Invocation::read(args).with_timeout(budget),
414    )
415    .await?;
416
417    let replies: Vec<PingReply> = lines(&text).filter_map(parse_pong).collect();
418    report(PingReport {
419        target: params.target,
420        reachable: !replies.is_empty(),
421        direct: replies.last().is_some_and(PingReply::is_direct),
422        replies,
423        raw: text.trim_end().to_owned(),
424    })
425}
426
427// ---------------------------------------------------------------------------
428// whois, whoami
429// ---------------------------------------------------------------------------
430
431/// Which transport the port in an address belongs to.
432#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)]
433#[serde(rename_all = "snake_case")]
434pub enum Proto {
435    Tcp,
436    Udp,
437}
438
439impl Proto {
440    const fn as_str(self) -> &'static str {
441        match self {
442            Self::Tcp => "tcp",
443            Self::Udp => "udp",
444        }
445    }
446}
447
448#[derive(Debug, Deserialize, JsonSchema)]
449pub struct WhoisParams {
450    /// A Tailscale address, optionally with a port (`100.64.0.2:22`).
451    pub address: String,
452    /// The transport the port belongs to, when one was given.
453    #[serde(default)]
454    pub proto: Option<Proto>,
455}
456
457async fn whois(ctx: &ToolContext, params: WhoisParams) -> ToolResult<Value> {
458    let mut args = vec!["whois".to_owned(), "--json=true".to_owned()];
459    if let Some(proto) = params.proto {
460        args.push(format!("--proto={}", proto.as_str()));
461    }
462    args.push(params.address);
463    object(ctx, &metas::tailscale_whois, Invocation::read(args)).await
464}
465
466async fn whoami(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
467    object(
468        ctx,
469        &metas::tailscale_whoami,
470        Invocation::read(["whoami", "--json=true"]),
471    )
472    .await
473}
474
475// ---------------------------------------------------------------------------
476// version
477// ---------------------------------------------------------------------------
478
479/// What `tailscale_version` returns.
480#[derive(Debug, Serialize, JsonSchema)]
481pub struct VersionReport {
482    /// The release the binary reports, when it could be read.
483    pub version: Option<String>,
484    /// Everything the binary printed, which also carries the commit and the Go
485    /// toolchain. Kept because it is what a bug report needs.
486    pub raw: String,
487    /// The oldest release this server is written against.
488    pub supported_floor: String,
489    /// Whether the binary is at or above that floor. An unstable build — an odd
490    /// minor number — counts as newer than the stable release above it. Null
491    /// when the version could not be read, because then nobody knows.
492    pub meets_floor: Option<bool>,
493}
494
495async fn version(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
496    let raw = cli::run_text(
497        ctx,
498        &metas::tailscale_version,
499        Invocation::read(["version"]),
500    )
501    .await?;
502    let version = Version::parse_cli_output(&raw);
503    report(VersionReport {
504        version: version.map(|v| v.to_string()),
505        raw: raw.trim_end().to_owned(),
506        supported_floor: SUPPORTED_FLOOR.to_string(),
507        meets_floor: version.map(|v| v >= SUPPORTED_FLOOR || v.is_unstable()),
508    })
509}
510
511// ---------------------------------------------------------------------------
512// licenses
513// ---------------------------------------------------------------------------
514
515#[derive(Debug, Serialize, JsonSchema)]
516pub struct LicenseReport {
517    /// Where the licences for this build are published. Usually one URL.
518    pub urls: Vec<String>,
519    pub text: String,
520}
521
522async fn licenses(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
523    let text = cli::run_text(
524        ctx,
525        &metas::tailscale_licenses,
526        Invocation::read(["licenses"]),
527    )
528    .await?;
529    report(LicenseReport {
530        urls: text
531            .split_whitespace()
532            .filter(|word| word.starts_with("https://") || word.starts_with("http://"))
533            .map(str::to_owned)
534            .collect(),
535        text: text.trim_end().to_owned(),
536    })
537}
538
539// ---------------------------------------------------------------------------
540// bugreport
541// ---------------------------------------------------------------------------
542
543#[derive(Debug, Deserialize, JsonSchema)]
544pub struct BugreportParams {
545    /// A note recorded alongside the marker, so that support can tell one
546    /// report from another.
547    #[serde(default)]
548    pub note: Option<String>,
549    /// Run extra local checks and print what they found.
550    #[serde(default)]
551    pub diagnose: bool,
552}
553
554#[derive(Debug, Serialize, JsonSchema)]
555pub struct BugreportOutcome {
556    /// The marker to quote to Tailscale support.
557    pub marker: Option<String>,
558    pub raw: String,
559}
560
561async fn bugreport(ctx: &ToolContext, params: BugreportParams) -> ToolResult<Value> {
562    let mut args = vec!["bugreport".to_owned(), flag("diagnose", params.diagnose)];
563    if let Some(note) = params.note {
564        args.push(note);
565    }
566    let text = cli::run_text(ctx, &metas::tailscale_bugreport, Invocation::read(args)).await?;
567    report(BugreportOutcome {
568        // The marker is the last thing printed; `--diagnose` puts its findings
569        // above it.
570        marker: lines(&text).next_back().map(str::to_owned),
571        raw: text.trim_end().to_owned(),
572    })
573}
574
575// ---------------------------------------------------------------------------
576// appc-routes
577// ---------------------------------------------------------------------------
578
579#[derive(Debug, Deserialize, JsonSchema)]
580pub struct AppcRoutesParams {
581    /// Report the routes policy supplies as well as the ones learnt by name.
582    #[serde(default)]
583    pub all: bool,
584    /// Report which domain each route was learnt from.
585    #[serde(default)]
586    pub map: bool,
587    /// Report only how many routes there are.
588    #[serde(default)]
589    pub count_only: bool,
590}
591
592#[derive(Debug, Serialize, JsonSchema)]
593pub struct AppcRoutesReport {
594    /// Whether this node is acting as an app connector at all.
595    pub app_connector: bool,
596    /// The routes it has learnt, when it is one.
597    pub routes: Vec<String>,
598    pub raw: String,
599}
600
601async fn appc_routes(ctx: &ToolContext, params: AppcRoutesParams) -> ToolResult<Value> {
602    let text = cli::run_text(
603        ctx,
604        &metas::tailscale_appc_routes,
605        Invocation::read([
606            "appc-routes".to_owned(),
607            flag("all", params.all),
608            flag("map", params.map),
609            flag("n", params.count_only),
610        ]),
611    )
612    .await?;
613    // A node that is not a connector says so and exits cleanly, so the sentence
614    // is the signal.
615    let connector = !text.to_ascii_lowercase().contains("not a connector");
616    report(AppcRoutesReport {
617        app_connector: connector,
618        routes: if connector {
619            lines(&text)
620                .filter(|line| line.contains('/'))
621                .map(str::to_owned)
622                .collect()
623        } else {
624            Vec::new()
625        },
626        raw: text.trim_end().to_owned(),
627    })
628}
629
630// ---------------------------------------------------------------------------
631// routecheck
632// ---------------------------------------------------------------------------
633
634#[derive(Debug, Deserialize, JsonSchema)]
635pub struct RoutecheckParams {
636    /// Run a fresh probe rather than reading the last report. Sends traffic.
637    #[serde(default)]
638    pub probe: bool,
639}
640
641#[derive(Debug, Serialize, JsonSchema)]
642pub struct RoutecheckReport {
643    /// Whether there was a report to read. There often is not until a probe has
644    /// been asked for, which is a state rather than a failure.
645    pub available: bool,
646    /// The report as the client printed it, unmodified.
647    pub report: Option<Value>,
648    /// What the client said when it had no report.
649    pub note: Option<String>,
650}
651
652async fn routecheck(ctx: &ToolContext, params: RoutecheckParams) -> ToolResult<Value> {
653    let output = cli::run_tolerant(
654        ctx,
655        &metas::tailscale_routecheck,
656        Invocation::read([
657            "routecheck".to_owned(),
658            "--format=json".to_owned(),
659            flag("probe", params.probe),
660        ]),
661    )
662    .await?;
663    let document = serde_json::from_str::<Value>(output.stdout_str().trim()).ok();
664    report(RoutecheckReport {
665        available: document.is_some(),
666        note: document
667            .is_none()
668            .then(|| note(ctx, &output.stderr))
669            .flatten(),
670        report: document,
671    })
672}
673
674// ---------------------------------------------------------------------------
675// wait
676// ---------------------------------------------------------------------------
677
678/// The longest one call will wait for the node to come up. The CLI's own
679/// default is zero, which means forever.
680const MAX_WAIT: u64 = 120;
681const DEFAULT_WAIT: u64 = 30;
682
683const fn default_wait() -> u64 {
684    DEFAULT_WAIT
685}
686
687#[derive(Debug, Deserialize, JsonSchema)]
688pub struct WaitParams {
689    /// How long to wait, in seconds. Capped at 120; there is no way to ask this
690    /// tool to wait forever.
691    #[serde(default = "default_wait")]
692    pub timeout_seconds: u64,
693}
694
695#[derive(Debug, Serialize, JsonSchema)]
696pub struct WaitReport {
697    /// Whether the node's interface and addresses were ready in time.
698    pub ready: bool,
699    /// The bound that was actually applied, after capping.
700    pub waited_up_to_seconds: u64,
701    pub note: Option<String>,
702}
703
704async fn wait(ctx: &ToolContext, params: WaitParams) -> ToolResult<Value> {
705    let (seconds, timeout) = bounded_wait(Some(params.timeout_seconds), MAX_WAIT, MAX_WAIT);
706    let output = cli::run_tolerant(
707        ctx,
708        &metas::tailscale_wait,
709        Invocation::read(["wait".to_owned(), format!("--timeout={seconds}s")])
710            .with_timeout(timeout),
711    )
712    .await?;
713    report(WaitReport {
714        ready: output.success(),
715        waited_up_to_seconds: seconds,
716        note: note(ctx, &output.stderr),
717    })
718}
719
720// ---------------------------------------------------------------------------
721// dns
722// ---------------------------------------------------------------------------
723
724#[derive(Debug, Deserialize, JsonSchema)]
725pub struct DnsStatusParams {
726    /// Include the forwarder's own debugging information.
727    #[serde(default)]
728    pub all: bool,
729}
730
731async fn dns_status(ctx: &ToolContext, params: DnsStatusParams) -> ToolResult<Value> {
732    object(
733        ctx,
734        &metas::tailscale_dns_status,
735        Invocation::read([
736            "dns".to_owned(),
737            "status".to_owned(),
738            "--json=true".to_owned(),
739            flag("all", params.all),
740        ]),
741    )
742    .await
743}
744
745#[derive(Debug, Deserialize, JsonSchema)]
746pub struct DnsQueryParams {
747    /// The name to resolve.
748    pub name: String,
749    /// The record type to ask for: `A`, `AAAA`, `CNAME`, `TXT` and so on.
750    /// Defaults to whatever the client asks for when told nothing.
751    #[serde(default)]
752    pub record_type: Option<String>,
753}
754
755async fn dns_query(ctx: &ToolContext, params: DnsQueryParams) -> ToolResult<Value> {
756    let mut args = vec![
757        "dns".to_owned(),
758        "query".to_owned(),
759        "--json=true".to_owned(),
760        params.name,
761    ];
762    if let Some(record_type) = params.record_type {
763        args.push(record_type);
764    }
765    object(ctx, &metas::tailscale_dns_query, Invocation::read(args)).await
766}
767
768// ---------------------------------------------------------------------------
769// exit nodes
770// ---------------------------------------------------------------------------
771
772#[derive(Debug, Deserialize, JsonSchema)]
773pub struct ExitNodeListParams {
774    /// Only report exit nodes in this country.
775    #[serde(default)]
776    pub filter: Option<String>,
777}
778
779/// One row of the exit-node table.
780#[derive(Debug, Serialize, JsonSchema, PartialEq)]
781pub struct ExitNode {
782    pub ip: String,
783    pub hostname: String,
784    /// `-` for a node in your own tailnet, which has no location.
785    pub country: String,
786    pub city: String,
787    /// `selected` for the node in use, `-` otherwise.
788    pub status: String,
789}
790
791#[derive(Debug, Serialize, JsonSchema)]
792pub struct ExitNodeListReport {
793    /// Whether there were any exit nodes at all. None is an answer, and the
794    /// client reports it by exiting non-zero.
795    pub available: bool,
796    pub exit_nodes: Vec<ExitNode>,
797    pub note: Option<String>,
798}
799
800/// Read the exit-node table.
801///
802/// Five whitespace-separated columns with a header row; a hostname never
803/// contains a space, so splitting on whitespace is sound.
804fn parse_exit_nodes(text: &str) -> Vec<ExitNode> {
805    lines(text)
806        .filter(|line| !line.starts_with("IP "))
807        .filter_map(|line| {
808            let columns: Vec<&str> = line.split_whitespace().collect();
809            let [ip, hostname, country, city, status] = columns[..] else {
810                return None;
811            };
812            Some(ExitNode {
813                ip: ip.to_owned(),
814                hostname: hostname.to_owned(),
815                country: country.to_owned(),
816                city: city.to_owned(),
817                status: status.to_owned(),
818            })
819        })
820        .collect()
821}
822
823async fn exit_node_list(ctx: &ToolContext, params: ExitNodeListParams) -> ToolResult<Value> {
824    let mut args = vec!["exit-node".to_owned(), "list".to_owned()];
825    if let Some(filter) = params.filter {
826        args.push(format!("--filter={filter}"));
827    }
828    let output = cli::run_tolerant(
829        ctx,
830        &metas::tailscale_exit_node_list,
831        Invocation::read(args),
832    )
833    .await?;
834    let exit_nodes = parse_exit_nodes(&output.stdout_str());
835    report(ExitNodeListReport {
836        available: !exit_nodes.is_empty(),
837        note: note(ctx, &output.stderr),
838        exit_nodes,
839    })
840}
841
842#[derive(Debug, Serialize, JsonSchema)]
843pub struct ExitNodeSuggestion {
844    /// The node the client would pick, when it has one to offer.
845    pub suggestion: Option<String>,
846    pub raw: String,
847}
848
849async fn exit_node_suggest(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
850    let output = cli::run_tolerant(
851        ctx,
852        &metas::tailscale_exit_node_suggest,
853        Invocation::read(["exit-node", "suggest"]),
854    )
855    .await?;
856    let text = output.stdout_str();
857    report(ExitNodeSuggestion {
858        suggestion: lines(&text).find_map(|line| {
859            line.split_once("Suggested exit node:")
860                .map(|(_, name)| name.trim().trim_end_matches('.').to_owned())
861        }),
862        raw: text.trim_end().to_owned(),
863    })
864}
865
866// ---------------------------------------------------------------------------
867// metrics
868// ---------------------------------------------------------------------------
869
870/// One metric sample.
871#[derive(Debug, Serialize, JsonSchema, PartialEq)]
872pub struct Metric {
873    pub name: String,
874    /// The labels in the sample's braces, empty when it had none.
875    pub labels: BTreeMap<String, String>,
876    pub value: f64,
877}
878
879#[derive(Debug, Serialize, JsonSchema)]
880pub struct MetricsReport {
881    pub metrics: Vec<Metric>,
882    /// The Prometheus text as it was printed, for anything that reads it
883    /// directly.
884    pub raw: String,
885}
886
887/// Read Prometheus exposition text.
888///
889/// Only the sample lines: `# TYPE` and `# HELP` are already dropped by
890/// [`lines`], and the client emits no timestamps or exemplars.
891fn parse_metrics(text: &str) -> Vec<Metric> {
892    lines(text)
893        .filter_map(|line| {
894            let (key, value) = line.rsplit_once(char::is_whitespace)?;
895            let value: f64 = value.trim().parse().ok()?;
896            let (name, labels) = match key.trim().split_once('{') {
897                Some((name, rest)) => (name, parse_labels(rest.trim_end_matches('}'))),
898                None => (key.trim(), BTreeMap::new()),
899            };
900            Some(Metric {
901                name: name.to_owned(),
902                labels,
903                value,
904            })
905        })
906        .collect()
907}
908
909fn parse_labels(inner: &str) -> BTreeMap<String, String> {
910    inner
911        .split(',')
912        .filter_map(|pair| pair.split_once('='))
913        .map(|(key, value)| {
914            (
915                key.trim().to_owned(),
916                value.trim().trim_matches('"').to_owned(),
917            )
918        })
919        .collect()
920}
921
922async fn metrics_print(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
923    let text = cli::run_text(
924        ctx,
925        &metas::tailscale_metrics_print,
926        Invocation::read(["metrics", "print"]),
927    )
928    .await?;
929    report(MetricsReport {
930        metrics: parse_metrics(&text),
931        raw: text.trim_end().to_owned(),
932    })
933}
934
935// ---------------------------------------------------------------------------
936// services, policy, accounts
937// ---------------------------------------------------------------------------
938
939async fn service_list(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
940    collection(
941        ctx,
942        &metas::tailscale_service_list,
943        Invocation::read(["service", "list", "--json=true"]),
944        "services",
945    )
946    .await
947}
948
949async fn syspolicy_list(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
950    object(
951        ctx,
952        &metas::tailscale_syspolicy_list,
953        Invocation::read(["syspolicy", "list", "--json=true"]),
954    )
955    .await
956}
957
958async fn switch_list(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
959    collection(
960        ctx,
961        &metas::tailscale_switch_list,
962        Invocation::read(["switch", "--list=true", "--json=true"]),
963        "accounts",
964    )
965    .await
966}
967
968// ---------------------------------------------------------------------------
969// tailnet lock
970// ---------------------------------------------------------------------------
971
972/// The client's own default, kept so that an unspecified call behaves the way
973/// the command line does.
974const DEFAULT_LOCK_LIMIT: u32 = 50;
975const MAX_LOCK_LIMIT: u32 = 500;
976
977const fn default_lock_limit() -> u32 {
978    DEFAULT_LOCK_LIMIT
979}
980
981#[derive(Debug, Deserialize, JsonSchema)]
982pub struct LockLogParams {
983    /// How many updates to report, newest first. Capped at 500.
984    #[serde(default = "default_lock_limit")]
985    pub limit: u32,
986}
987
988async fn lock_status(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
989    object(
990        ctx,
991        &metas::tailscale_lock_status,
992        Invocation::read(["lock", "status", "--json=true"]),
993    )
994    .await
995}
996
997async fn lock_log(ctx: &ToolContext, params: LockLogParams) -> ToolResult<Value> {
998    let limit = params.limit.clamp(1, MAX_LOCK_LIMIT);
999    collection(
1000        ctx,
1001        &metas::tailscale_lock_log,
1002        Invocation::read([
1003            "lock".to_owned(),
1004            "log".to_owned(),
1005            "--json=true".to_owned(),
1006            format!("--limit={limit}"),
1007        ]),
1008        "updates",
1009    )
1010    .await
1011}
1012
1013// ---------------------------------------------------------------------------
1014// serve and funnel
1015// ---------------------------------------------------------------------------
1016
1017async fn serve_status(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
1018    object(
1019        ctx,
1020        &metas::tailscale_serve_status,
1021        Invocation::read(["serve", "status", "--json=true"]),
1022    )
1023    .await
1024}
1025
1026async fn funnel_status(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
1027    object(
1028        ctx,
1029        &metas::tailscale_funnel_status,
1030        Invocation::read(["funnel", "status", "--json=true"]),
1031    )
1032    .await
1033}
1034
1035// ---------------------------------------------------------------------------
1036// host configuration
1037// ---------------------------------------------------------------------------
1038
1039#[derive(Debug, Serialize, JsonSchema)]
1040pub struct SysextReport {
1041    /// The state as the client words it, for example `OK (activated enabled)`.
1042    pub state: Option<String>,
1043    pub raw: String,
1044}
1045
1046async fn configure_sysext_status(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
1047    let text = cli::run_text(
1048        ctx,
1049        &metas::tailscale_configure_sysext_status,
1050        Invocation::read(["configure", "sysext", "status"]),
1051    )
1052    .await?;
1053    report(SysextReport {
1054        state: lines(&text).find_map(|line| {
1055            line.split_once("System extension state:")
1056                .map(|(_, state)| state.trim().to_owned())
1057        }),
1058        raw: text.trim_end().to_owned(),
1059    })
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064    use std::sync::Arc;
1065
1066    use super::*;
1067
1068    use crate::testing::{Reply, StubBackend, context};
1069
1070    /// A recorded sample of what the real client prints.
1071    macro_rules! fixture {
1072        ($name:literal) => {
1073            include_str!(concat!("../../tests/fixtures/", $name))
1074        };
1075    }
1076
1077    /// Run a handler against a scripted client and report both what it answered
1078    /// and what it ran.
1079    async fn against<F, P, Fut>(reply: Reply, handler: F, params: P) -> (Value, Vec<Vec<String>>)
1080    where
1081        F: FnOnce(ToolContext, P) -> Fut,
1082        Fut: Future<Output = ToolResult<Value>>,
1083    {
1084        let backend = Arc::new(StubBackend::always(reply));
1085        let ctx = context(Arc::clone(&backend));
1086        let value = handler(ctx, params).await.expect("the handler succeeds");
1087        (value, backend.argv())
1088    }
1089
1090    // -- what each tool runs --------------------------------------------------
1091
1092    #[tokio::test]
1093    async fn status_asks_for_json_and_passes_its_switches_joined_to_their_values() {
1094        let (_, argv) = against(
1095            Reply::ok(fixture!("status.json")),
1096            |ctx, p| async move { status(&ctx, p).await },
1097            StatusParams {
1098                peers: false,
1099                include_self: true,
1100                active: true,
1101            },
1102        )
1103        .await;
1104        assert_eq!(
1105            argv,
1106            [[
1107                "status",
1108                "--json=true",
1109                "--peers=false",
1110                "--self=true",
1111                "--active=true"
1112            ]]
1113        );
1114    }
1115
1116    #[tokio::test]
1117    async fn the_status_document_is_forwarded_exactly_as_the_client_printed_it() {
1118        let (value, _) = against(
1119            Reply::ok(fixture!("status.json")),
1120            |ctx, p| async move { status(&ctx, p).await },
1121            StatusParams {
1122                peers: true,
1123                include_self: true,
1124                active: false,
1125            },
1126        )
1127        .await;
1128        let expected: Value = serde_json::from_str(fixture!("status.json")).unwrap();
1129        assert_eq!(value, expected);
1130    }
1131
1132    #[tokio::test]
1133    async fn a_stopped_node_reports_its_state_rather_than_failing() {
1134        // `status` exits non-zero when the backend is not running, but the
1135        // document it prints is exactly what the caller asked for.
1136        let backend = Arc::new(StubBackend::always(Reply::Ran(tailscale_cli::Output {
1137            exit_code: Some(1),
1138            stdout: fixture!("status-stopped.json").as_bytes().to_vec(),
1139            stderr: "Tailscale is stopped.\n".to_owned(),
1140        })));
1141        let ctx = context(backend);
1142        let value = status(
1143            &ctx,
1144            StatusParams {
1145                peers: true,
1146                include_self: true,
1147                active: false,
1148            },
1149        )
1150        .await
1151        .expect("a stopped node is an answer, not a failure");
1152        assert_eq!(value["BackendState"], "Stopped");
1153    }
1154
1155    #[tokio::test]
1156    async fn ip_selects_a_family_with_a_joined_flag_and_puts_the_target_last() {
1157        let (value, argv) = against(
1158            Reply::ok(fixture!("ip.txt")),
1159            |ctx, p| async move { ip(&ctx, p).await },
1160            IpParams {
1161                target: Some("laptop".to_owned()),
1162                family: AddressFamily::Ipv4,
1163            },
1164        )
1165        .await;
1166        assert_eq!(argv, [["ip", "--4=true", "laptop"]]);
1167        assert_eq!(value["addresses"][0], "100.64.0.1");
1168        assert_eq!(value["addresses"][1], "fd7a:115c:a1e0::1");
1169        assert_eq!(value["target"], "laptop");
1170    }
1171
1172    #[tokio::test]
1173    async fn netcheck_uses_the_format_flag_it_predates_the_json_one_with() {
1174        let (value, argv) = against(
1175            Reply::ok(fixture!("netcheck.json")),
1176            |ctx, p| async move { netcheck(&ctx, p).await },
1177            NoParams {},
1178        )
1179        .await;
1180        assert_eq!(argv, [["netcheck", "--format=json"]]);
1181        assert_eq!(value["PreferredDERP"], 12);
1182    }
1183
1184    #[tokio::test]
1185    async fn netcheck_ignores_the_log_lines_the_client_writes_to_standard_error() {
1186        let backend = Arc::new(StubBackend::always(Reply::Ran(tailscale_cli::Output {
1187            exit_code: Some(0),
1188            stdout: fixture!("netcheck.json").as_bytes().to_vec(),
1189            stderr: "2026/09/04 12:00:00 portmap: probing\n# Warning: unstable\n".to_owned(),
1190        })));
1191        let ctx = context(backend);
1192        let value = netcheck(&ctx, NoParams {}).await.expect("succeeds");
1193        assert_eq!(value["UDP"], true);
1194    }
1195
1196    #[tokio::test]
1197    async fn whois_puts_its_flags_before_the_address() {
1198        let (value, argv) = against(
1199            Reply::ok(fixture!("whois.json")),
1200            |ctx, p| async move { whois(&ctx, p).await },
1201            WhoisParams {
1202                address: "100.64.0.2:22".to_owned(),
1203                proto: Some(Proto::Tcp),
1204            },
1205        )
1206        .await;
1207        assert_eq!(
1208            argv,
1209            [["whois", "--json=true", "--proto=tcp", "100.64.0.2:22"]]
1210        );
1211        assert_eq!(value["Node"]["ComputedName"], "laptop");
1212    }
1213
1214    #[tokio::test]
1215    async fn dns_query_puts_the_record_type_after_the_name() {
1216        let (value, argv) = against(
1217            Reply::ok(fixture!("dns-query.json")),
1218            |ctx, p| async move { dns_query(&ctx, p).await },
1219            DnsQueryParams {
1220                name: "laptop.example-tailnet.ts.net".to_owned(),
1221                record_type: Some("A".to_owned()),
1222            },
1223        )
1224        .await;
1225        assert_eq!(
1226            argv,
1227            [[
1228                "dns",
1229                "query",
1230                "--json=true",
1231                "laptop.example-tailnet.ts.net",
1232                "A"
1233            ]]
1234        );
1235        assert_eq!(value["ResponseCode"], "NOERROR");
1236    }
1237
1238    #[tokio::test]
1239    async fn a_list_document_is_named_by_what_it_holds() {
1240        let (value, argv) = against(
1241            Reply::ok(fixture!("service-list.json")),
1242            |ctx, p| async move { service_list(&ctx, p).await },
1243            NoParams {},
1244        )
1245        .await;
1246        assert_eq!(argv, [["service", "list", "--json=true"]]);
1247        assert_eq!(value["services"][0]["Name"], "svc:web");
1248    }
1249
1250    #[tokio::test]
1251    async fn an_empty_list_and_a_null_list_are_the_same_answer() {
1252        for printed in ["[]", "null"] {
1253            let (value, _) = against(
1254                Reply::ok(printed),
1255                |ctx, p| async move { switch_list(&ctx, p).await },
1256                NoParams {},
1257            )
1258            .await;
1259            assert_eq!(value["accounts"], json!([]), "printed {printed}");
1260        }
1261    }
1262
1263    #[tokio::test]
1264    async fn the_lock_log_limit_is_capped_and_joined_to_its_flag() {
1265        let (_, argv) = against(
1266            Reply::ok(fixture!("lock-log.json")),
1267            |ctx, p| async move { lock_log(&ctx, p).await },
1268            LockLogParams { limit: 100_000 },
1269        )
1270        .await;
1271        assert_eq!(
1272            argv,
1273            [[
1274                "lock",
1275                "log",
1276                "--json=true",
1277                &format!("--limit={MAX_LOCK_LIMIT}")
1278            ]]
1279        );
1280    }
1281
1282    #[tokio::test]
1283    async fn a_version_that_cannot_be_read_is_not_said_to_meet_the_floor() {
1284        for (printed, meets_floor) in [
1285            (
1286                "The Tailscale GUI failed to start: (Tailscale.CLIError error 3.)\n",
1287                Value::Null,
1288            ),
1289            ("1.72.0\n", Value::Bool(false)),
1290            (fixture!("tailscale-version.txt"), Value::Bool(true)),
1291        ] {
1292            let (value, _) = against(
1293                Reply::ok(printed),
1294                |ctx, p| async move { version(&ctx, p).await },
1295                NoParams {},
1296            )
1297            .await;
1298            assert_eq!(value["meets_floor"], meets_floor, "{value}");
1299        }
1300    }
1301
1302    // -- parsers --------------------------------------------------------------
1303
1304    #[test]
1305    fn a_relayed_reply_and_a_direct_reply_are_told_apart() {
1306        let replies: Vec<PingReply> = lines(fixture!("ping.txt")).filter_map(parse_pong).collect();
1307        assert_eq!(
1308            replies,
1309            [
1310                PingReply {
1311                    peer: "laptop".to_owned(),
1312                    address: "100.64.0.2".to_owned(),
1313                    via: "DERP(sfo)".to_owned(),
1314                    latency_ms: Some(41.0),
1315                },
1316                PingReply {
1317                    peer: "laptop".to_owned(),
1318                    address: "100.64.0.2".to_owned(),
1319                    via: "203.0.113.9:41641".to_owned(),
1320                    latency_ms: Some(12.0),
1321                },
1322            ]
1323        );
1324        assert!(!replies[0].is_direct());
1325        assert!(replies[1].is_direct());
1326    }
1327
1328    #[test]
1329    fn a_line_that_is_not_a_reply_is_not_read_as_one() {
1330        for line in [
1331            "no matching peer",
1332            "pong from laptop",
1333            "direct connection established",
1334            "",
1335        ] {
1336            assert!(parse_pong(line).is_none(), "{line}");
1337        }
1338    }
1339
1340    #[tokio::test]
1341    async fn ping_honours_its_default_and_its_cap() {
1342        let (value, argv) = against(
1343            Reply::ok(fixture!("ping.txt")),
1344            |ctx, p| async move { ping(&ctx, p).await },
1345            PingParams {
1346                target: "laptop".to_owned(),
1347                count: default_ping_count(),
1348                timeout_seconds: default_ping_timeout(),
1349                method: PingMethod::Disco,
1350                until_direct: true,
1351            },
1352        )
1353        .await;
1354        assert_eq!(
1355            argv,
1356            [[
1357                "ping",
1358                &format!("--c={DEFAULT_PING_COUNT}"),
1359                &format!("--timeout={DEFAULT_PING_TIMEOUT}s"),
1360                "--until-direct=true",
1361                "laptop"
1362            ]]
1363        );
1364        assert_eq!(value["reachable"], true);
1365        assert_eq!(value["direct"], true);
1366        assert_eq!(value["replies"].as_array().map(Vec::len), Some(2));
1367
1368        let (_, argv) = against(
1369            Reply::ok(""),
1370            |ctx, p| async move { ping(&ctx, p).await },
1371            PingParams {
1372                target: "laptop".to_owned(),
1373                count: 10_000,
1374                timeout_seconds: 10_000,
1375                method: PingMethod::Icmp,
1376                until_direct: false,
1377            },
1378        )
1379        .await;
1380        assert_eq!(
1381            argv,
1382            [[
1383                "ping",
1384                &format!("--c={MAX_PING_COUNT}"),
1385                &format!("--timeout={MAX_PING_TIMEOUT}s"),
1386                "--until-direct=false",
1387                "--icmp=true",
1388                "laptop"
1389            ]]
1390        );
1391    }
1392
1393    #[tokio::test]
1394    async fn a_bounded_ping_bounds_the_process_too() {
1395        let backend = Arc::new(StubBackend::always(Reply::ok("")));
1396        let ctx = context(Arc::clone(&backend));
1397        ping(
1398            &ctx,
1399            PingParams {
1400                target: "laptop".to_owned(),
1401                count: 4,
1402                timeout_seconds: 3,
1403                method: PingMethod::Disco,
1404                until_direct: true,
1405            },
1406        )
1407        .await
1408        .expect("succeeds");
1409        let timeout = backend.calls()[0].timeout;
1410        assert_eq!(timeout, Duration::from_secs(4 * 3 + 5));
1411    }
1412
1413    #[test]
1414    fn the_exit_node_table_is_read_without_its_header_or_its_footnotes() {
1415        let nodes = parse_exit_nodes(fixture!("exit-node-list.txt"));
1416        assert_eq!(
1417            nodes,
1418            [
1419                ExitNode {
1420                    ip: "100.64.0.2".to_owned(),
1421                    hostname: "laptop.example-tailnet.ts.net".to_owned(),
1422                    country: "-".to_owned(),
1423                    city: "-".to_owned(),
1424                    status: "-".to_owned(),
1425                },
1426                ExitNode {
1427                    ip: "100.64.0.3".to_owned(),
1428                    hostname: "se-sto-wg-001.example-tailnet.ts.net".to_owned(),
1429                    country: "Sweden".to_owned(),
1430                    city: "Stockholm".to_owned(),
1431                    status: "selected".to_owned(),
1432                },
1433            ]
1434        );
1435    }
1436
1437    #[tokio::test]
1438    async fn no_exit_nodes_is_an_answer_rather_than_a_failure() {
1439        let ctx = context(Arc::new(StubBackend::failure(1, "no exit nodes found\n")));
1440        let value = exit_node_list(&ctx, ExitNodeListParams { filter: None })
1441            .await
1442            .expect("an empty tailnet is an answer");
1443        assert_eq!(value["available"], false);
1444        assert_eq!(value["exit_nodes"], json!([]));
1445        assert_eq!(value["note"], "no exit nodes found");
1446    }
1447
1448    #[tokio::test]
1449    async fn a_suggestion_is_lifted_out_of_the_sentence_it_arrives_in() {
1450        let (value, argv) = against(
1451            Reply::ok(fixture!("exit-node-suggest.txt")),
1452            |ctx, p| async move { exit_node_suggest(&ctx, p).await },
1453            NoParams {},
1454        )
1455        .await;
1456        assert_eq!(argv, [["exit-node", "suggest"]]);
1457        assert_eq!(value["suggestion"], "laptop.example-tailnet.ts.net");
1458
1459        let (value, _) = against(
1460            Reply::ok("No exit node suggestion is available.\n"),
1461            |ctx, p| async move { exit_node_suggest(&ctx, p).await },
1462            NoParams {},
1463        )
1464        .await;
1465        assert_eq!(value["suggestion"], Value::Null);
1466    }
1467
1468    #[test]
1469    fn prometheus_samples_are_read_with_their_labels() {
1470        let metrics = parse_metrics(fixture!("metrics.txt"));
1471        assert_eq!(
1472            metrics[0],
1473            Metric {
1474                name: "tailscaled_advertised_routes".to_owned(),
1475                labels: BTreeMap::new(),
1476                value: 0.0,
1477            }
1478        );
1479        let inbound: Vec<&Metric> = metrics
1480            .iter()
1481            .filter(|m| m.name == "tailscaled_inbound_bytes_total")
1482            .collect();
1483        assert_eq!(inbound.len(), 2);
1484        assert_eq!(inbound[0].labels["path"], "derp");
1485        assert_eq!(inbound[0].value, 4096.0);
1486        assert_eq!(inbound[1].labels["path"], "direct_ipv4");
1487        // The `# TYPE` and `# HELP` lines are not samples.
1488        assert!(
1489            metrics.iter().all(|m| !m.name.starts_with('#')),
1490            "{metrics:?}"
1491        );
1492    }
1493
1494    #[tokio::test]
1495    async fn a_node_that_is_not_an_app_connector_says_so_and_reports_no_routes() {
1496        let (value, argv) = against(
1497            Reply::ok("not a connector\n"),
1498            |ctx, p| async move { appc_routes(&ctx, p).await },
1499            AppcRoutesParams {
1500                all: false,
1501                map: false,
1502                count_only: false,
1503            },
1504        )
1505        .await;
1506        assert_eq!(
1507            argv,
1508            [["appc-routes", "--all=false", "--map=false", "--n=false"]]
1509        );
1510        assert_eq!(value["app_connector"], false);
1511        assert_eq!(value["routes"], json!([]));
1512    }
1513
1514    #[tokio::test]
1515    async fn an_app_connector_reports_the_routes_it_has_learnt() {
1516        let (value, _) = against(
1517            Reply::ok(fixture!("appc-routes.txt")),
1518            |ctx, p| async move { appc_routes(&ctx, p).await },
1519            AppcRoutesParams {
1520                all: true,
1521                map: false,
1522                count_only: false,
1523            },
1524        )
1525        .await;
1526        assert_eq!(value["app_connector"], true);
1527        assert_eq!(value["routes"], json!(["192.0.2.0/24", "198.51.100.0/24"]));
1528    }
1529
1530    #[tokio::test]
1531    async fn a_routecheck_with_no_report_yet_is_a_state_not_a_failure() {
1532        let ctx = context(Arc::new(StubBackend::failure(
1533            1,
1534            "routecheck: report pending\n",
1535        )));
1536        let value = routecheck(&ctx, RoutecheckParams { probe: false })
1537            .await
1538            .expect("a pending report is an answer");
1539        assert_eq!(value["available"], false);
1540        assert_eq!(value["report"], Value::Null);
1541        assert_eq!(value["note"], "routecheck: report pending");
1542    }
1543
1544    #[tokio::test]
1545    async fn a_routecheck_report_is_forwarded_whole() {
1546        let (value, argv) = against(
1547            Reply::ok(fixture!("routecheck.json")),
1548            |ctx, p| async move { routecheck(&ctx, p).await },
1549            RoutecheckParams { probe: true },
1550        )
1551        .await;
1552        assert_eq!(argv, [["routecheck", "--format=json", "--probe=true"]]);
1553        assert_eq!(value["available"], true);
1554        let expected: Value = serde_json::from_str(fixture!("routecheck.json")).unwrap();
1555        assert_eq!(value["report"], expected);
1556    }
1557
1558    #[tokio::test]
1559    async fn waiting_is_bounded_and_a_timeout_is_reported_rather_than_raised() {
1560        let (value, argv) = against(
1561            Reply::ok(""),
1562            |ctx, p| async move { wait(&ctx, p).await },
1563            WaitParams {
1564                timeout_seconds: 10_000,
1565            },
1566        )
1567        .await;
1568        assert_eq!(argv, [["wait", &format!("--timeout={MAX_WAIT}s")]]);
1569        assert_eq!(value["ready"], true);
1570        assert_eq!(value["waited_up_to_seconds"], MAX_WAIT);
1571
1572        let backend = Arc::new(StubBackend::failure(1, "timeout\n"));
1573        let ctx = context(Arc::clone(&backend));
1574        let value = wait(&ctx, WaitParams { timeout_seconds: 5 })
1575            .await
1576            .expect("a timeout is an answer");
1577        assert_eq!(value["ready"], false);
1578        assert_eq!(value["note"], "timeout");
1579        assert_eq!(backend.calls()[0].timeout, Duration::from_secs(5 + 5));
1580    }
1581
1582    #[tokio::test]
1583    async fn the_bug_report_marker_is_the_last_thing_printed() {
1584        let (value, argv) = against(
1585            Reply::ok(fixture!("bugreport.txt")),
1586            |ctx, p| async move { bugreport(&ctx, p).await },
1587            BugreportParams {
1588                note: Some("slow handshake".to_owned()),
1589                diagnose: true,
1590            },
1591        )
1592        .await;
1593        assert_eq!(argv, [["bugreport", "--diagnose=true", "slow handshake"]]);
1594        assert_eq!(value["marker"], fixture!("bugreport.txt").trim());
1595    }
1596
1597    #[tokio::test]
1598    async fn the_licence_url_is_lifted_out_of_the_prose_around_it() {
1599        let (value, _) = against(
1600            Reply::ok(fixture!("licenses.txt")),
1601            |ctx, p| async move { licenses(&ctx, p).await },
1602            NoParams {},
1603        )
1604        .await;
1605        assert_eq!(
1606            value["urls"],
1607            json!(["https://tailscale.com/licenses/apple"])
1608        );
1609    }
1610
1611    #[tokio::test]
1612    async fn the_system_extension_state_is_lifted_out_of_its_sentence() {
1613        let (value, argv) = against(
1614            Reply::ok(fixture!("sysext-status.txt")),
1615            |ctx, p| async move { configure_sysext_status(&ctx, p).await },
1616            NoParams {},
1617        )
1618        .await;
1619        assert_eq!(argv, [["configure", "sysext", "status"]]);
1620        assert_eq!(value["state"], "OK (activated enabled)");
1621    }
1622
1623    // -- the table ------------------------------------------------------------
1624
1625    #[test]
1626    fn every_tool_here_is_a_read_in_the_status_toolset() {
1627        for entry in entries() {
1628            assert_eq!(
1629                entry.meta.tier,
1630                crate::meta::Tier::Read,
1631                "`{}` changes something and does not belong here",
1632                entry.meta.name
1633            );
1634            assert_eq!(
1635                entry.meta.toolset,
1636                crate::meta::Toolset::LocalStatus,
1637                "{}",
1638                entry.meta.name
1639            );
1640            assert!(
1641                !entry.meta.requires_confirmation,
1642                "`{}` reads, so nothing needs confirming",
1643                entry.meta.name
1644            );
1645        }
1646    }
1647}