1use 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 tailscale_status => StatusParams, status,
35 toolset: LocalStatus, tier: Read, idempotent: true;
36
37 tailscale_ip => IpParams, ip,
40 toolset: LocalStatus, tier: Read, idempotent: true;
41
42 tailscale_netcheck => NoParams, netcheck,
46 toolset: LocalStatus, tier: Read, idempotent: true;
47
48 tailscale_ping => PingParams, ping,
51 toolset: LocalStatus, tier: Read, idempotent: true;
52
53 tailscale_whois => WhoisParams, whois,
55 toolset: LocalStatus, tier: Read, idempotent: true;
56
57 tailscale_whoami => NoParams, whoami,
59 toolset: LocalStatus, tier: Read, idempotent: true, since: "1.90";
60
61 tailscale_version => NoParams, version,
64 toolset: LocalStatus, tier: Read, idempotent: true;
65
66 tailscale_licenses => NoParams, licenses,
69 toolset: LocalStatus, tier: Read, idempotent: true;
70
71 tailscale_bugreport => BugreportParams, bugreport,
75 toolset: LocalStatus, tier: Read, idempotent: false;
76
77 tailscale_appc_routes => AppcRoutesParams, appc_routes,
80 toolset: LocalStatus, tier: Read, idempotent: true, since: "1.90";
81
82 tailscale_routecheck => RoutecheckParams, routecheck,
86 toolset: LocalStatus, tier: Read, idempotent: true, since: "1.102";
87
88 tailscale_wait => WaitParams, wait,
91 toolset: LocalStatus, tier: Read, idempotent: true, since: "1.90";
92
93 tailscale_dns_status => DnsStatusParams, dns_status,
97 toolset: LocalStatus, tier: Read, idempotent: true, since: "1.72";
98
99 tailscale_dns_query => DnsQueryParams, dns_query,
102 toolset: LocalStatus, tier: Read, idempotent: true, since: "1.72";
103
104 tailscale_exit_node_list => ExitNodeListParams, exit_node_list,
107 toolset: LocalStatus, tier: Read, idempotent: true;
108
109 tailscale_exit_node_suggest => NoParams, exit_node_suggest,
111 toolset: LocalStatus, tier: Read, idempotent: true, since: "1.66";
112
113 tailscale_metrics_print => NoParams, metrics_print,
116 toolset: LocalStatus, tier: Read, idempotent: true, since: "1.78";
117
118 tailscale_service_list => NoParams, service_list,
120 toolset: LocalStatus, tier: Read, idempotent: true, since: "1.90";
121
122 tailscale_syspolicy_list => NoParams, syspolicy_list,
125 toolset: LocalStatus, tier: Read, idempotent: true, since: "1.72";
126
127 tailscale_lock_status => NoParams, lock_status,
130 toolset: LocalStatus, tier: Read, idempotent: true;
131
132 tailscale_lock_log => LockLogParams, lock_log,
134 toolset: LocalStatus, tier: Read, idempotent: true;
135
136 tailscale_serve_status => NoParams, serve_status,
139 toolset: LocalStatus, tier: Read, idempotent: true;
140
141 tailscale_funnel_status => NoParams, funnel_status,
144 toolset: LocalStatus, tier: Read, idempotent: true;
145
146 tailscale_configure_sysext_status => NoParams, configure_sysext_status,
149 toolset: LocalStatus, tier: Read, idempotent: true, platforms: ["macos"];
150
151 tailscale_switch_list => NoParams, switch_list,
154 toolset: LocalStatus, tier: Read, idempotent: true;
155}
156
157#[derive(Debug, Deserialize, JsonSchema)]
163pub struct NoParams {}
164
165const fn yes() -> bool {
167 true
168}
169
170async 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#[derive(Debug, Deserialize, JsonSchema)]
194pub struct StatusParams {
195 #[serde(default = "yes")]
198 pub peers: bool,
199 #[serde(default = "yes")]
201 pub include_self: bool,
202 #[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#[derive(Debug, Default, Clone, Copy, Deserialize, JsonSchema)]
228#[serde(rename_all = "snake_case")]
229pub enum AddressFamily {
230 #[default]
232 Any,
233 Ipv4,
234 Ipv6,
235}
236
237#[derive(Debug, Deserialize, JsonSchema)]
238pub struct IpParams {
239 #[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 pub target: Option<String>,
251 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) = ¶ms.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
272async fn netcheck(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
277 object(
278 ctx,
279 &metas::tailscale_netcheck,
280 Invocation::read(["netcheck", "--format=json"]),
283 )
284 .await
285}
286
287const MAX_PING_COUNT: u32 = 20;
295const MAX_PING_TIMEOUT: u64 = 30;
297const 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#[derive(Debug, Default, Clone, Copy, Deserialize, JsonSchema)]
310#[serde(rename_all = "snake_case")]
311pub enum PingMethod {
312 #[default]
314 Disco,
315 Icmp,
317 Tsmp,
319 Peerapi,
321}
322
323#[derive(Debug, Deserialize, JsonSchema)]
324pub struct PingParams {
325 pub target: String,
327 #[serde(default = "default_ping_count")]
329 pub count: u32,
330 #[serde(default = "default_ping_timeout")]
332 pub timeout_seconds: u64,
333 #[serde(default)]
334 pub method: PingMethod,
335 #[serde(default = "yes")]
337 pub until_direct: bool,
338}
339
340#[derive(Debug, Serialize, JsonSchema, PartialEq)]
342pub struct PingReply {
343 pub peer: String,
345 pub address: String,
347 pub via: String,
350 pub latency_ms: Option<f64>,
352}
353
354impl PingReply {
355 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 pub reachable: bool,
366 pub direct: bool,
368 pub replies: Vec<PingReply>,
369 pub raw: String,
371}
372
373fn 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 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#[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 pub address: String,
452 #[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#[derive(Debug, Serialize, JsonSchema)]
481pub struct VersionReport {
482 pub version: Option<String>,
484 pub raw: String,
487 pub supported_floor: String,
489 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#[derive(Debug, Serialize, JsonSchema)]
516pub struct LicenseReport {
517 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#[derive(Debug, Deserialize, JsonSchema)]
544pub struct BugreportParams {
545 #[serde(default)]
548 pub note: Option<String>,
549 #[serde(default)]
551 pub diagnose: bool,
552}
553
554#[derive(Debug, Serialize, JsonSchema)]
555pub struct BugreportOutcome {
556 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 marker: lines(&text).next_back().map(str::to_owned),
571 raw: text.trim_end().to_owned(),
572 })
573}
574
575#[derive(Debug, Deserialize, JsonSchema)]
580pub struct AppcRoutesParams {
581 #[serde(default)]
583 pub all: bool,
584 #[serde(default)]
586 pub map: bool,
587 #[serde(default)]
589 pub count_only: bool,
590}
591
592#[derive(Debug, Serialize, JsonSchema)]
593pub struct AppcRoutesReport {
594 pub app_connector: bool,
596 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 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#[derive(Debug, Deserialize, JsonSchema)]
635pub struct RoutecheckParams {
636 #[serde(default)]
638 pub probe: bool,
639}
640
641#[derive(Debug, Serialize, JsonSchema)]
642pub struct RoutecheckReport {
643 pub available: bool,
646 pub report: Option<Value>,
648 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
674const 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 #[serde(default = "default_wait")]
692 pub timeout_seconds: u64,
693}
694
695#[derive(Debug, Serialize, JsonSchema)]
696pub struct WaitReport {
697 pub ready: bool,
699 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#[derive(Debug, Deserialize, JsonSchema)]
725pub struct DnsStatusParams {
726 #[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 pub name: String,
749 #[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#[derive(Debug, Deserialize, JsonSchema)]
773pub struct ExitNodeListParams {
774 #[serde(default)]
776 pub filter: Option<String>,
777}
778
779#[derive(Debug, Serialize, JsonSchema, PartialEq)]
781pub struct ExitNode {
782 pub ip: String,
783 pub hostname: String,
784 pub country: String,
786 pub city: String,
787 pub status: String,
789}
790
791#[derive(Debug, Serialize, JsonSchema)]
792pub struct ExitNodeListReport {
793 pub available: bool,
796 pub exit_nodes: Vec<ExitNode>,
797 pub note: Option<String>,
798}
799
800fn 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 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#[derive(Debug, Serialize, JsonSchema, PartialEq)]
872pub struct Metric {
873 pub name: String,
874 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 pub raw: String,
885}
886
887fn 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
935async 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
968const 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 #[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
1013async 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#[derive(Debug, Serialize, JsonSchema)]
1040pub struct SysextReport {
1041 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 macro_rules! fixture {
1072 ($name:literal) => {
1073 include_str!(concat!("../../tests/fixtures/", $name))
1074 };
1075 }
1076
1077 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 #[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 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 #[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 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 #[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}