1use rmcp::schemars::JsonSchema;
68use serde::{Deserialize, Serialize};
69use serde_json::Value;
70use tailscale_cli::Invocation;
71
72use crate::cli;
73use crate::context::ToolContext;
74use crate::error::{ErrorCode, ToolError, ToolResult};
75use crate::meta::Tier;
76use crate::tools::common::{Excluded, bounded_wait, printed, report};
77use crate::tools::local_debug;
78
79crate::tools! {
80 tailscale_run => RunParams, run,
88 toolset: LocalPassthrough, tier: Read, varying: true;
89}
90
91const MAX_DEPTH: usize = 3;
95
96const DEFAULT_RUN_TIMEOUT: u64 = 30;
99const MAX_RUN_TIMEOUT: u64 = 300;
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub struct Covered {
108 pub path: &'static str,
111 pub tier: Tier,
116 pub confirm: bool,
118}
119
120pub const COVERED: &[Covered] = &[
127 Covered {
129 path: "up",
130 tier: Tier::Destructive,
131 confirm: true,
132 },
133 Covered {
134 path: "down",
135 tier: Tier::Destructive,
136 confirm: true,
137 },
138 Covered {
139 path: "set",
140 tier: Tier::Write,
141 confirm: false,
142 },
143 Covered {
144 path: "get",
145 tier: Tier::Read,
146 confirm: false,
147 },
148 Covered {
149 path: "login",
150 tier: Tier::Write,
151 confirm: true,
152 },
153 Covered {
154 path: "logout",
155 tier: Tier::Destructive,
156 confirm: true,
157 },
158 Covered {
159 path: "switch",
160 tier: Tier::Write,
161 confirm: true,
162 },
163 Covered {
164 path: "switch remove",
165 tier: Tier::Destructive,
166 confirm: true,
167 },
168 Covered {
170 path: "status",
171 tier: Tier::Read,
172 confirm: false,
173 },
174 Covered {
175 path: "ip",
176 tier: Tier::Read,
177 confirm: false,
178 },
179 Covered {
180 path: "netcheck",
181 tier: Tier::Read,
182 confirm: false,
183 },
184 Covered {
185 path: "ping",
186 tier: Tier::Read,
187 confirm: false,
188 },
189 Covered {
190 path: "whois",
191 tier: Tier::Read,
192 confirm: false,
193 },
194 Covered {
195 path: "whoami",
196 tier: Tier::Read,
197 confirm: false,
198 },
199 Covered {
200 path: "version",
201 tier: Tier::Read,
202 confirm: false,
203 },
204 Covered {
205 path: "licenses",
206 tier: Tier::Read,
207 confirm: false,
208 },
209 Covered {
210 path: "bugreport",
211 tier: Tier::Read,
212 confirm: false,
213 },
214 Covered {
215 path: "appc-routes",
216 tier: Tier::Read,
217 confirm: false,
218 },
219 Covered {
220 path: "routecheck",
221 tier: Tier::Read,
222 confirm: false,
223 },
224 Covered {
225 path: "wait",
226 tier: Tier::Read,
227 confirm: false,
228 },
229 Covered {
230 path: "dns status",
231 tier: Tier::Read,
232 confirm: false,
233 },
234 Covered {
235 path: "dns query",
236 tier: Tier::Read,
237 confirm: false,
238 },
239 Covered {
240 path: "exit-node list",
241 tier: Tier::Read,
242 confirm: false,
243 },
244 Covered {
245 path: "exit-node suggest",
246 tier: Tier::Read,
247 confirm: false,
248 },
249 Covered {
250 path: "metrics print",
251 tier: Tier::Read,
252 confirm: false,
253 },
254 Covered {
255 path: "service list",
256 tier: Tier::Read,
257 confirm: false,
258 },
259 Covered {
260 path: "syspolicy list",
261 tier: Tier::Read,
262 confirm: false,
263 },
264 Covered {
265 path: "configure sysext status",
266 tier: Tier::Read,
267 confirm: false,
268 },
269 Covered {
271 path: "serve",
272 tier: Tier::Write,
273 confirm: false,
274 },
275 Covered {
276 path: "serve status",
277 tier: Tier::Read,
278 confirm: false,
279 },
280 Covered {
281 path: "serve reset",
282 tier: Tier::Destructive,
283 confirm: true,
284 },
285 Covered {
286 path: "serve drain",
287 tier: Tier::Write,
288 confirm: false,
289 },
290 Covered {
291 path: "serve clear",
292 tier: Tier::Destructive,
293 confirm: true,
294 },
295 Covered {
296 path: "serve advertise",
297 tier: Tier::Write,
298 confirm: false,
299 },
300 Covered {
301 path: "serve get-config",
302 tier: Tier::Read,
303 confirm: false,
304 },
305 Covered {
306 path: "serve set-config",
307 tier: Tier::Write,
308 confirm: false,
309 },
310 Covered {
311 path: "funnel",
312 tier: Tier::Destructive,
313 confirm: true,
314 },
315 Covered {
316 path: "funnel status",
317 tier: Tier::Read,
318 confirm: false,
319 },
320 Covered {
322 path: "file cp",
323 tier: Tier::Write,
324 confirm: false,
325 },
326 Covered {
327 path: "file get",
328 tier: Tier::Write,
329 confirm: false,
330 },
331 Covered {
332 path: "cert",
333 tier: Tier::Write,
334 confirm: false,
335 },
336 Covered {
337 path: "metrics write",
338 tier: Tier::Write,
339 confirm: false,
340 },
341 Covered {
342 path: "configure kubeconfig",
343 tier: Tier::Write,
344 confirm: false,
345 },
346 Covered {
347 path: "syspolicy reload",
348 tier: Tier::Write,
349 confirm: false,
350 },
351 Covered {
352 path: "drive list",
353 tier: Tier::Read,
354 confirm: false,
355 },
356 Covered {
357 path: "drive share",
358 tier: Tier::Write,
359 confirm: false,
360 },
361 Covered {
362 path: "drive rename",
363 tier: Tier::Write,
364 confirm: false,
365 },
366 Covered {
367 path: "drive unshare",
368 tier: Tier::Destructive,
369 confirm: false,
370 },
371 Covered {
373 path: "lock status",
374 tier: Tier::Read,
375 confirm: false,
376 },
377 Covered {
378 path: "lock log",
379 tier: Tier::Read,
380 confirm: false,
381 },
382 Covered {
383 path: "lock init",
384 tier: Tier::Destructive,
385 confirm: true,
386 },
387 Covered {
388 path: "lock add",
389 tier: Tier::Write,
390 confirm: false,
391 },
392 Covered {
393 path: "lock remove",
394 tier: Tier::Destructive,
395 confirm: false,
396 },
397 Covered {
398 path: "lock sign",
399 tier: Tier::Write,
400 confirm: false,
401 },
402 Covered {
403 path: "lock disable",
404 tier: Tier::Destructive,
405 confirm: true,
406 },
407 Covered {
408 path: "lock disablement-kdf",
409 tier: Tier::Read,
410 confirm: false,
411 },
412 Covered {
413 path: "lock local-disable",
414 tier: Tier::Destructive,
415 confirm: false,
416 },
417 Covered {
418 path: "lock revoke-keys",
419 tier: Tier::Destructive,
420 confirm: true,
421 },
422 Covered {
424 path: "debug derp-map",
425 tier: Tier::Read,
426 confirm: false,
427 },
428 Covered {
429 path: "debug netmap",
430 tier: Tier::Read,
431 confirm: false,
432 },
433 Covered {
434 path: "debug hostinfo",
435 tier: Tier::Read,
436 confirm: false,
437 },
438 Covered {
439 path: "debug control-knobs",
440 tier: Tier::Read,
441 confirm: false,
442 },
443 Covered {
444 path: "debug daemon-goroutines",
445 tier: Tier::Read,
446 confirm: false,
447 },
448 Covered {
449 path: "debug daemon-bus-graph",
450 tier: Tier::Read,
451 confirm: false,
452 },
453 Covered {
454 path: "debug daemon-bus-queues",
455 tier: Tier::Read,
456 confirm: false,
457 },
458 Covered {
459 path: "debug metrics",
460 tier: Tier::Read,
461 confirm: false,
462 },
463 Covered {
464 path: "debug statedir",
465 tier: Tier::Read,
466 confirm: false,
467 },
468 Covered {
469 path: "debug go-buildinfo",
470 tier: Tier::Read,
471 confirm: false,
472 },
473 Covered {
474 path: "debug peer-relay-servers",
475 tier: Tier::Read,
476 confirm: false,
477 },
478 Covered {
479 path: "debug peer-relay-sessions",
480 tier: Tier::Read,
481 confirm: false,
482 },
483 Covered {
484 path: "debug stat",
485 tier: Tier::Read,
486 confirm: false,
487 },
488 Covered {
489 path: "debug via",
490 tier: Tier::Read,
491 confirm: false,
492 },
493 Covered {
494 path: "debug watch-ipn",
495 tier: Tier::Read,
496 confirm: false,
497 },
498 Covered {
499 path: "debug peer-endpoint-changes",
500 tier: Tier::Read,
501 confirm: false,
502 },
503 Covered {
504 path: "debug resolve",
505 tier: Tier::Read,
506 confirm: false,
507 },
508 Covered {
509 path: "debug dial-types",
510 tier: Tier::Read,
511 confirm: false,
512 },
513 Covered {
514 path: "debug derp",
515 tier: Tier::Read,
516 confirm: false,
517 },
518 Covered {
519 path: "debug ts2021",
520 tier: Tier::Read,
521 confirm: false,
522 },
523 Covered {
524 path: "debug portmap",
525 tier: Tier::Read,
526 confirm: false,
527 },
528 Covered {
529 path: "debug component-logs",
530 tier: Tier::Write,
531 confirm: false,
532 },
533 Covered {
534 path: "debug restun",
535 tier: Tier::Write,
536 confirm: false,
537 },
538 Covered {
539 path: "debug rebind",
540 tier: Tier::Write,
541 confirm: false,
542 },
543 Covered {
544 path: "debug rotate-disco-key",
545 tier: Tier::Write,
546 confirm: false,
547 },
548 Covered {
549 path: "debug derp-unset-on-demand",
550 tier: Tier::Write,
551 confirm: false,
552 },
553 Covered {
554 path: "debug pick-new-derp",
555 tier: Tier::Write,
556 confirm: false,
557 },
558 Covered {
559 path: "debug force-prefer-derp",
560 tier: Tier::Write,
561 confirm: false,
562 },
563 Covered {
564 path: "debug force-netmap-update",
565 tier: Tier::Write,
566 confirm: false,
567 },
568];
569
570const EXCLUDED: &[Excluded] = &[
579 Excluded {
580 path: "ssh",
581 reason: "it opens an interactive session on a peer, which needs a terminal \
582 this server does not have",
583 },
584 Excluded {
585 path: "nc",
586 reason: "it wires a socket to standard input and output, which a tool call \
587 has nothing to connect",
588 },
589 Excluded {
590 path: "web",
591 reason: "it runs a web server in the foreground until interrupted",
592 },
593 Excluded {
594 path: "systray",
595 reason: "it runs a desktop application in the foreground until quit",
596 },
597 Excluded {
598 path: "update",
599 reason: "it replaces the `tailscale` binary this server is talking to; \
600 update the host the way the host is normally updated",
601 },
602 Excluded {
603 path: "configure sysext activate",
604 reason: "it installs a system extension and reboots into it, which is a \
605 change to the host rather than to the tailnet",
606 },
607 Excluded {
608 path: "configure sysext deactivate",
609 reason: "it removes a system extension the node may be running on, which is \
610 a change to the host rather than to the tailnet",
611 },
612 Excluded {
613 path: "configure mac-vpn install",
614 reason: "it installs a system VPN profile, which is a change to the host \
615 rather than to the tailnet",
616 },
617 Excluded {
618 path: "configure mac-vpn uninstall",
619 reason: "it removes a system VPN profile, which is a change to the host \
620 rather than to the tailnet",
621 },
622];
623
624pub fn excluded() -> impl Iterator<Item = &'static Excluded> {
630 EXCLUDED.iter().chain(local_debug::EXCLUDED)
631}
632
633#[derive(Debug, Clone, Copy, PartialEq, Eq)]
635pub enum Known {
636 Covered(&'static Covered),
638 Excluded(&'static Excluded),
640 Unknown,
642}
643
644pub fn classify(args: &[String]) -> ToolResult<(String, Known)> {
651 let leading = words(args.iter().take_while(|arg| !is_flag(arg)));
664 let every = words(args.iter().filter(|arg| !is_flag(arg)));
665
666 if every.first().is_none_or(String::is_empty) {
667 return Err(ToolError::invalid_args(
668 "the first argument has to be a subcommand rather than a flag or an \
669 empty string, so that what runs can be read from the arguments",
670 ));
671 }
672
673 let (path, known) = if leading.is_empty() {
674 read(&every)
675 } else {
676 std::cmp::max_by_key(read(&leading), read(&every), |(_, known)| strictness(known))
677 };
678
679 if known == Known::Unknown {
686 for reading in [&leading, &every] {
687 if excluded().any(|e| is_proper_prefix(reading, e.path)) {
688 return Err(ToolError::invalid_args(format!(
689 "`tailscale {}` names only part of a command, and this server \
690 decides what to allow from the words that are not flags. Write \
691 the whole subcommand, with its flags after it.",
692 reading.join(" ")
693 )));
694 }
695 }
696 }
697 Ok((path, known))
698}
699
700fn is_flag(arg: &str) -> bool {
702 arg.starts_with('-')
703}
704
705fn words<'a>(args: impl Iterator<Item = &'a String>) -> Vec<String> {
713 args.map(|arg| arg.trim().to_ascii_lowercase()).collect()
714}
715
716fn read(words: &[String]) -> (String, Known) {
718 for depth in (1..=words.len().min(MAX_DEPTH)).rev() {
719 let path = words[..depth].join(" ");
720 if let Some(found) = excluded().find(|e| e.path == path) {
721 return (path, Known::Excluded(found));
722 }
723 if let Some(found) = COVERED.iter().find(|c| c.path == path) {
724 return (path, Known::Covered(found));
725 }
726 }
727 (words.join(" "), Known::Unknown)
728}
729
730fn strictness(known: &Known) -> (bool, Tier, bool) {
736 match known {
737 Known::Excluded(_) => (true, Tier::Destructive, true),
738 Known::Covered(covered) => (false, covered.tier, covered.confirm),
739 Known::Unknown => (false, Tier::Destructive, false),
740 }
741}
742
743fn is_proper_prefix(words: &[String], path: &str) -> bool {
745 let mut theirs = path.split(' ');
746 !words.is_empty()
747 && words
748 .iter()
749 .all(|word| theirs.next() == Some(word.as_str()))
750 && theirs.next().is_some()
751}
752
753#[derive(Debug, Deserialize, JsonSchema)]
755pub struct RunParams {
756 pub args: Vec<String>,
761 #[serde(default)]
765 pub confirm: bool,
766 #[serde(default)]
770 pub timeout_seconds: Option<u64>,
771}
772
773#[derive(Debug, Serialize, JsonSchema)]
775pub struct RunReport {
776 pub command: String,
778 pub tier: &'static str,
780 pub covered: bool,
784 #[serde(skip_serializing_if = "Option::is_none")]
786 pub printed: Option<String>,
787}
788
789async fn run(ctx: &ToolContext, params: RunParams) -> ToolResult<Value> {
790 let meta = &metas::tailscale_run;
791 let (path, known) = classify(¶ms.args)?;
792
793 let (tier, confirm) = match known {
794 Known::Excluded(excluded) => {
795 return Err(ToolError::new(
798 ErrorCode::NotPermitted,
799 format!(
800 "`tailscale {}` is never run by this server: {}",
801 excluded.path, excluded.reason
802 ),
803 ));
804 }
805 Known::Covered(covered) => (covered.tier, covered.confirm),
806 Known::Unknown => (Tier::Destructive, false),
807 };
808
809 if tier > ctx.max_tier
812 && let Some(flag) = tier.flag()
813 {
814 return Err(ToolError::not_permitted(&format!("tailscale {path}"), flag));
815 }
816 if confirm && !params.confirm {
819 return Err(ToolError::confirmation_required(
820 &format!("tailscale {path}"),
821 "runs at the same terms as the tool that covers it",
822 ));
823 }
824
825 let (_, bound) = bounded_wait(params.timeout_seconds, DEFAULT_RUN_TIMEOUT, MAX_RUN_TIMEOUT);
826 let invocation = if tier == Tier::Read {
830 Invocation::read(params.args.clone())
831 } else {
832 Invocation::mutate(params.args.clone())
833 }
834 .with_timeout(bound);
835
836 let command = cli::displayed(ctx, &invocation);
837 tracing::info!(command = %command, tier = tier.as_str(), "passthrough");
838 let output = cli::run(ctx, meta, invocation).await?;
839 report(RunReport {
840 command,
841 tier: tier.as_str(),
842 covered: matches!(known, Known::Covered(_)),
843 printed: printed(ctx, &output),
844 })
845}
846
847#[cfg(test)]
848mod tests {
849 use super::*;
850 use crate::testing::{Reply, StubBackend, context};
851 use std::sync::Arc;
852
853 fn words(args: &[&str]) -> Vec<String> {
854 args.iter().map(|a| (*a).to_owned()).collect()
855 }
856
857 fn class(args: &[&str]) -> Known {
858 classify(&words(args))
859 .expect("the arguments name a command")
860 .1
861 }
862
863 #[test]
864 fn every_path_fits_the_depth_the_matcher_searches() {
865 for path in COVERED
866 .iter()
867 .map(|c| c.path)
868 .chain(excluded().map(|e| e.path))
869 {
870 assert!(
871 path.split(' ').count() <= MAX_DEPTH,
872 "`{path}` is deeper than the matcher looks"
873 );
874 }
875 }
876
877 #[test]
878 fn no_path_is_named_twice() {
879 let mut seen: Vec<&str> = COVERED
880 .iter()
881 .map(|c| c.path)
882 .chain(excluded().map(|e| e.path))
883 .collect();
884 let before = seen.len();
885 seen.sort_unstable();
886 seen.dedup();
887 assert_eq!(before, seen.len(), "a command is judged in two places");
888 }
889
890 #[test]
891 fn every_path_is_written_the_way_the_matcher_compares_it() {
892 for path in COVERED
895 .iter()
896 .map(|c| c.path)
897 .chain(excluded().map(|e| e.path))
898 {
899 assert_eq!(path, path.to_ascii_lowercase(), "`{path}` cannot match");
900 assert_eq!(path.trim(), path, "`{path}` cannot match");
901 }
902 }
903
904 #[test]
905 fn a_partial_path_into_an_excluded_command_is_refused_rather_than_guessed() {
906 let error = classify(&words(&["debug", "--file=get"]))
909 .expect_err("`debug` on its own cannot be judged");
910 assert_eq!(error.code, ErrorCode::InvalidArgs);
911 assert!(
912 error.message.contains("only part of a command"),
913 "{error:?}"
914 );
915 }
916
917 #[test]
918 fn a_flag_value_that_only_looks_like_a_subcommand_keeps_the_stricter_terms() {
919 let (path, known) = classify(&words(&["funnel", "--set-path", "status", "8080"]))
923 .expect("`funnel` is readable");
924 assert_eq!(path, "funnel");
925 let Known::Covered(covered) = known else {
926 panic!("`funnel` has a row");
927 };
928 assert_eq!((covered.tier, covered.confirm), (Tier::Destructive, true));
929 }
930
931 #[test]
932 fn a_blank_first_argument_is_refused_rather_than_run_as_a_nameless_command() {
933 for args in [vec![""], vec!["", "down"], vec![" "]] {
936 let args: Vec<String> = args.into_iter().map(str::to_owned).collect();
937 let error = classify(&args).expect_err("a blank subcommand cannot be read");
938 assert_eq!(error.code, ErrorCode::InvalidArgs);
939 }
940 let args: Vec<String> = ["ping", ""].into_iter().map(str::to_owned).collect();
943 let (path, _) = classify(&args).expect("`ping` is still readable");
944 assert_eq!(path, "ping");
945 }
946
947 #[test]
948 fn a_leading_flag_does_not_hide_the_subcommand_behind_it() {
949 let (path, known) =
952 classify(&words(&["--socket=/tmp/x", "status"])).expect("`status` is still named");
953 assert_eq!(path, "status");
954 assert!(matches!(known, Known::Covered(_)));
955 }
956
957 #[test]
958 fn reload_config_stays_runnable() {
959 assert!(matches!(class(&["debug", "reload-config"]), Known::Unknown));
962 }
963
964 async fn against(reply: Reply, params: RunParams) -> (ToolResult<Value>, Vec<Vec<String>>) {
966 let backend = Arc::new(StubBackend::always(reply));
967 let ctx = context(backend.clone());
968 let answer = run(&ctx, params).await;
969 (answer, backend.argv())
970 }
971
972 fn params(args: &[&str]) -> RunParams {
973 RunParams {
974 args: words(args),
975 confirm: false,
976 timeout_seconds: None,
977 }
978 }
979
980 #[tokio::test]
981 async fn the_report_says_how_the_command_was_judged() {
982 let (answer, _) = against(Reply::ok("100.64.0.1\n"), params(&["ip"])).await;
983 let value = answer.expect("a read runs");
984 assert_eq!(value["tier"], "read");
985 assert_eq!(value["covered"], true);
986 assert_eq!(value["command"], "tailscale ip");
987 assert_eq!(value["printed"], "100.64.0.1");
988 }
989
990 #[tokio::test]
991 async fn a_secret_on_the_argument_list_is_kept_out_of_the_report() {
992 let key = "tskey-auth-kFakeExample-0123456789";
996 let (answer, argv) = against(
997 Reply::ok("done\n"),
998 RunParams {
999 args: words(&["up", &format!("--auth-key={key}")]),
1000 confirm: true,
1001 timeout_seconds: None,
1002 },
1003 )
1004 .await;
1005 let value = answer.expect("`up` runs once confirmed");
1006 let command = value["command"].as_str().expect("a command line");
1007 assert!(
1008 !command.contains(key),
1009 "the key reached the report: {command}"
1010 );
1011
1012 assert_eq!(argv, vec![words(&["up", &format!("--auth-key={key}")])]);
1015 }
1016}