1use std::time::Duration;
20
21use rmcp::schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24use tailscale_cli::{Invocation, SecretFile};
25
26use crate::cli;
27use crate::context::ToolContext;
28use crate::error::{ErrorCode, ToolError, ToolResult};
29use crate::tools::common::{
30 bounded_wait, find_url, flag, note, only_on, push_bool, push_list, push_text, report,
31 secret_value,
32};
33
34crate::tools! {
35 tailscale_prefs_get => PrefsGetParams, prefs_get,
38 toolset: LocalPrefs, tier: Read, idempotent: true, since: "1.90";
39
40 tailscale_prefs_set => PrefsSetParams, prefs_set,
45 toolset: LocalPrefs, tier: Write, idempotent: true;
46
47 tailscale_up => UpParams, up,
57 toolset: LocalPrefs, tier: Destructive, severing: true;
58
59 tailscale_down => DownParams, down,
62 toolset: LocalPrefs, tier: Destructive, severing: true;
63
64 tailscale_login => LoginParams, login,
68 toolset: LocalPrefs, tier: Write, severing: true;
69
70 tailscale_logout => LogoutParams, logout,
73 toolset: LocalPrefs, tier: Destructive, severing: true;
74
75 tailscale_switch_profile => SwitchParams, switch_profile,
79 toolset: LocalPrefs, tier: Write, severing: true;
80
81 tailscale_switch_remove => SwitchRemoveParams, switch_remove,
84 toolset: LocalPrefs, tier: Destructive, confirm: true;
85}
86
87const DEFAULT_CONNECT_TIMEOUT: u64 = 60;
93const MAX_CONNECT_TIMEOUT: u64 = 300;
96const ACCEPTED_RISKS: &str = "--accept-risk=all";
101
102const LINUX_ONLY: &[&str] = &["linux"];
108const WINDOWS_ONLY: &[&str] = &["windows"];
109
110#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)]
112#[serde(rename_all = "snake_case")]
113pub enum NetfilterMode {
114 On,
116 Nodivert,
118 Off,
120}
121
122impl NetfilterMode {
123 const fn as_str(self) -> &'static str {
124 match self {
125 Self::On => "on",
126 Self::Nodivert => "nodivert",
127 Self::Off => "off",
128 }
129 }
130}
131
132macro_rules! prefs_params {
140 (
141 $(#[doc = $doc:literal])*
142 $name:ident { $( $(#[doc = $field_doc:literal])* $field:ident : $ty:ty ),* $(,)? }
143 ) => {
144 $(#[doc = $doc])*
145 #[derive(Debug, Default, Deserialize, JsonSchema)]
146 pub struct $name {
147 #[serde(default)]
150 pub accept_dns: Option<bool>,
151 #[serde(default)]
153 pub accept_routes: Option<bool>,
154 #[serde(default)]
156 pub advertise_connector: Option<bool>,
157 #[serde(default)]
160 pub advertise_exit_node: Option<bool>,
161 #[serde(default)]
164 pub advertise_routes: Option<Vec<String>>,
165 #[serde(default)]
169 pub exit_node: Option<String>,
170 #[serde(default)]
173 pub exit_node_allow_lan_access: Option<bool>,
174 #[serde(default)]
177 pub hostname: Option<String>,
178 #[serde(default)]
180 pub operator: Option<String>,
181 #[serde(default)]
183 pub report_posture: Option<bool>,
184 #[serde(default)]
186 pub shields_up: Option<bool>,
187 #[serde(default)]
189 pub ssh: Option<bool>,
190 #[serde(default)]
193 pub snat_subnet_routes: Option<bool>,
194 #[serde(default)]
197 pub stateful_filtering: Option<bool>,
198 #[serde(default)]
200 pub netfilter_mode: Option<NetfilterMode>,
201 #[serde(default)]
204 pub unattended: Option<bool>,
205 $(
206 $(#[doc = $field_doc])*
207 #[serde(default)]
208 pub $field: $ty,
209 )*
210 }
211
212 impl $name {
213 fn shared_flags(&self) -> Vec<String> {
215 let mut args = Vec::new();
216 push_bool(&mut args, "accept-dns", self.accept_dns);
217 push_bool(&mut args, "accept-routes", self.accept_routes);
218 push_bool(&mut args, "advertise-connector", self.advertise_connector);
219 push_bool(&mut args, "advertise-exit-node", self.advertise_exit_node);
220 push_list(&mut args, "advertise-routes", self.advertise_routes.as_deref());
221 push_text(&mut args, "exit-node", self.exit_node.as_deref());
222 push_bool(
223 &mut args,
224 "exit-node-allow-lan-access",
225 self.exit_node_allow_lan_access,
226 );
227 push_text(&mut args, "hostname", self.hostname.as_deref());
228 push_text(&mut args, "operator", self.operator.as_deref());
229 push_bool(&mut args, "report-posture", self.report_posture);
230 push_bool(&mut args, "shields-up", self.shields_up);
231 push_bool(&mut args, "ssh", self.ssh);
232 args
233 }
234
235 fn platform_flags(&self) -> ToolResult<Vec<String>> {
239 let mut args = Vec::new();
240 if let Some(value) = self.snat_subnet_routes {
241 only_on("snat_subnet_routes", LINUX_ONLY)?;
242 args.push(flag("snat-subnet-routes", value));
243 }
244 if let Some(value) = self.stateful_filtering {
245 only_on("stateful_filtering", LINUX_ONLY)?;
246 args.push(flag("stateful-filtering", value));
247 }
248 if let Some(mode) = self.netfilter_mode {
249 only_on("netfilter_mode", LINUX_ONLY)?;
250 args.push(format!("--netfilter-mode={}", mode.as_str()));
251 }
252 if let Some(value) = self.unattended {
253 only_on("unattended", WINDOWS_ONLY)?;
254 args.push(flag("unattended", value));
255 }
256 Ok(args)
257 }
258 }
259 };
260}
261
262prefs_params! {
263 PrefsSetParams {
265 auto_update: Option<bool>,
267 update_check: Option<bool>,
269 webclient: Option<bool>,
271 nickname: Option<String>,
273 relay_server_port: Option<String>,
276 relay_server_static_endpoints: Option<Vec<String>>,
279 }
280}
281
282prefs_params! {
283 UpParams {
286 advertise_tags: Option<Vec<String>>,
288 auth_key: Option<String>,
293 login_server: Option<String>,
295 timeout_seconds: Option<u64>,
298 force_reauth: Option<bool>,
301 reset: Option<bool>,
304 }
305}
306
307prefs_params! {
308 LoginParams {
310 advertise_tags: Option<Vec<String>>,
312 auth_key: Option<String>,
317 login_server: Option<String>,
319 nickname: Option<String>,
321 timeout_seconds: Option<u64>,
324 }
325}
326
327fn connect_timeouts(requested: Option<u64>) -> (u64, Duration) {
333 bounded_wait(requested, DEFAULT_CONNECT_TIMEOUT, MAX_CONNECT_TIMEOUT)
334}
335
336fn secret_argument(name: &str, value: &str) -> ToolResult<(String, Option<SecretFile>)> {
339 let (value, file) = secret_value(name, value)?;
340 Ok((format!("--{name}={value}"), file))
341}
342
343#[derive(Debug, Deserialize, JsonSchema)]
348pub struct PrefsGetParams {
349 #[serde(default)]
353 pub setting: Option<String>,
354 #[serde(default)]
358 pub as_set_flags: bool,
359}
360
361#[derive(Debug, Serialize, JsonSchema)]
362pub struct PrefsReport {
363 pub setting: Option<String>,
365 #[serde(skip_serializing_if = "Option::is_none")]
367 pub preferences: Option<Value>,
368 #[serde(skip_serializing_if = "Option::is_none")]
371 pub set_flags: Option<String>,
372}
373
374async fn prefs_get(ctx: &ToolContext, params: PrefsGetParams) -> ToolResult<Value> {
375 let meta = &metas::tailscale_prefs_get;
376 let mut args = vec!["get".to_owned()];
377 args.push(flag(
378 if params.as_set_flags {
379 "set-flags"
380 } else {
381 "json"
382 },
383 true,
384 ));
385 if let Some(setting) = ¶ms.setting {
387 args.push(setting.replace('_', "-"));
388 }
389 let text = cli::run_text(ctx, meta, Invocation::read(args)).await?;
390 if params.as_set_flags {
391 return report(PrefsReport {
392 setting: params.setting,
393 preferences: None,
394 set_flags: Some(text.trim().to_owned()),
395 });
396 }
397 let preferences = serde_json::from_str::<Value>(text.trim()).map_err(|e| {
398 ToolError::new(
399 ErrorCode::CliFailed,
400 format!("`tailscale get` did not print JSON: {e}"),
401 )
402 })?;
403 report(PrefsReport {
404 setting: params.setting,
405 preferences: Some(preferences),
406 set_flags: None,
407 })
408}
409
410#[derive(Debug, Serialize, JsonSchema)]
415pub struct PrefsSetReport {
416 pub applied: Vec<String>,
419 #[serde(skip_serializing_if = "Option::is_none")]
421 pub note: Option<String>,
422}
423
424async fn prefs_set(ctx: &ToolContext, params: PrefsSetParams) -> ToolResult<Value> {
425 let meta = &metas::tailscale_prefs_set;
426 let mut flags = params.shared_flags();
427 flags.extend(params.platform_flags()?);
428 push_bool(&mut flags, "auto-update", params.auto_update);
429 push_bool(&mut flags, "update-check", params.update_check);
430 push_bool(&mut flags, "webclient", params.webclient);
431 push_text(&mut flags, "nickname", params.nickname.as_deref());
432 push_text(
433 &mut flags,
434 "relay-server-port",
435 params.relay_server_port.as_deref(),
436 );
437 push_list(
438 &mut flags,
439 "relay-server-static-endpoints",
440 params.relay_server_static_endpoints.as_deref(),
441 );
442 if flags.is_empty() {
443 return Err(ToolError::invalid_args(
444 "name at least one preference to change; `tailscale_prefs_set` \
445 changes only what it is given",
446 ));
447 }
448
449 let mut args = vec!["set".to_owned()];
450 args.extend(flags.iter().cloned());
451 let output = cli::run(ctx, meta, Invocation::mutate(args)).await?;
452 report(PrefsSetReport {
453 applied: flags,
454 note: note(ctx, &output.stderr),
455 })
456}
457
458#[derive(Debug, Serialize, JsonSchema)]
463pub struct ConnectReport {
464 #[serde(skip_serializing_if = "Option::is_none")]
467 pub state: Option<Value>,
468 #[serde(skip_serializing_if = "Option::is_none")]
471 pub login_url: Option<String>,
472 pub timeout_seconds: u64,
474 #[serde(skip_serializing_if = "Option::is_none")]
476 pub note: Option<String>,
477}
478
479async fn up(ctx: &ToolContext, params: UpParams) -> ToolResult<Value> {
480 let meta = &metas::tailscale_up;
481 let (seconds, budget) = connect_timeouts(params.timeout_seconds);
482
483 let mut args = vec!["up".to_owned(), flag("json", true)];
484 args.extend(params.shared_flags());
485 args.extend(params.platform_flags()?);
486 push_list(
487 &mut args,
488 "advertise-tags",
489 params.advertise_tags.as_deref(),
490 );
491 push_text(&mut args, "login-server", params.login_server.as_deref());
492 push_bool(&mut args, "force-reauth", params.force_reauth);
493 push_bool(&mut args, "reset", params.reset);
494 args.push(format!("--timeout={seconds}s"));
495 args.push(ACCEPTED_RISKS.to_owned());
498
499 let mut key = None;
501 if let Some(value) = ¶ms.auth_key {
502 let (argument, file) = secret_argument("auth-key", value)?;
503 args.push(argument);
504 key = file;
505 }
506
507 let output = cli::run(ctx, meta, Invocation::mutate(args).with_timeout(budget)).await?;
508 drop(key);
509 let stdout = output.stdout_str();
510 let state = serde_json::from_str::<Value>(stdout.trim()).ok();
511 let login_url = state
512 .as_ref()
513 .and_then(|s| s["AuthURL"].as_str().map(str::to_owned))
514 .filter(|url| !url.is_empty())
515 .or_else(|| find_url(&stdout));
516 report(ConnectReport {
517 state,
518 login_url,
519 timeout_seconds: seconds,
520 note: note(ctx, &output.stderr),
521 })
522}
523
524#[derive(Debug, Deserialize, JsonSchema)]
529pub struct DownParams {
530 #[serde(default)]
533 pub reason: Option<String>,
534}
535
536#[derive(Debug, Serialize, JsonSchema)]
537pub struct DisconnectReport {
538 pub outcome: &'static str,
541 #[serde(skip_serializing_if = "Option::is_none")]
542 pub note: Option<String>,
543}
544
545async fn down(ctx: &ToolContext, params: DownParams) -> ToolResult<Value> {
546 let mut args = vec!["down".to_owned(), ACCEPTED_RISKS.to_owned()];
547 push_text(&mut args, "reason", params.reason.as_deref());
548 let output = cli::run(ctx, &metas::tailscale_down, Invocation::mutate(args)).await?;
549 report(DisconnectReport {
550 outcome: "disconnected",
551 note: note(ctx, &output.stderr),
552 })
553}
554
555#[derive(Debug, Serialize, JsonSchema)]
560pub struct LoginReport {
561 #[serde(skip_serializing_if = "Option::is_none")]
564 pub login_url: Option<String>,
565 pub timeout_seconds: u64,
567 pub output: String,
569 #[serde(skip_serializing_if = "Option::is_none")]
570 pub note: Option<String>,
571}
572
573async fn login(ctx: &ToolContext, params: LoginParams) -> ToolResult<Value> {
574 let meta = &metas::tailscale_login;
575 let (seconds, budget) = connect_timeouts(params.timeout_seconds);
576
577 let mut args = vec!["login".to_owned()];
578 args.extend(params.shared_flags());
579 args.extend(params.platform_flags()?);
580 push_list(
581 &mut args,
582 "advertise-tags",
583 params.advertise_tags.as_deref(),
584 );
585 push_text(&mut args, "login-server", params.login_server.as_deref());
586 push_text(&mut args, "nickname", params.nickname.as_deref());
587 args.push(format!("--timeout={seconds}s"));
588
589 let mut key = None;
590 if let Some(value) = ¶ms.auth_key {
591 let (argument, file) = secret_argument("auth-key", value)?;
592 args.push(argument);
593 key = file;
594 }
595
596 let output = cli::run(ctx, meta, Invocation::mutate(args).with_timeout(budget)).await?;
597 drop(key);
598 let printed = ctx.redactor.apply(&output.stdout_str()).trim().to_owned();
599 report(LoginReport {
600 login_url: find_url(&printed),
601 timeout_seconds: seconds,
602 output: printed,
603 note: note(ctx, &output.stderr),
604 })
605}
606
607#[derive(Debug, Deserialize, JsonSchema)]
612pub struct LogoutParams {
613 #[serde(default)]
616 pub reason: Option<String>,
617}
618
619async fn logout(ctx: &ToolContext, params: LogoutParams) -> ToolResult<Value> {
620 let mut args = vec!["logout".to_owned()];
621 push_text(&mut args, "reason", params.reason.as_deref());
622 let output = cli::run(ctx, &metas::tailscale_logout, Invocation::mutate(args)).await?;
623 report(DisconnectReport {
624 outcome: "logged out",
625 note: note(ctx, &output.stderr),
626 })
627}
628
629#[derive(Debug, Deserialize, JsonSchema)]
634pub struct SwitchParams {
635 pub account: String,
638}
639
640#[derive(Debug, Serialize, JsonSchema)]
641pub struct SwitchReport {
642 pub account: String,
644 pub outcome: &'static str,
645 #[serde(skip_serializing_if = "Option::is_none")]
646 pub note: Option<String>,
647}
648
649async fn switch_profile(ctx: &ToolContext, params: SwitchParams) -> ToolResult<Value> {
650 let output = cli::run(
651 ctx,
652 &metas::tailscale_switch_profile,
653 Invocation::mutate(["switch".to_owned(), params.account.clone()]),
654 )
655 .await?;
656 report(SwitchReport {
657 account: params.account,
658 outcome: "switched",
659 note: note(ctx, &output.stderr),
660 })
661}
662
663#[derive(Debug, Deserialize, JsonSchema)]
664pub struct SwitchRemoveParams {
665 pub account: String,
668}
669
670async fn switch_remove(ctx: &ToolContext, params: SwitchRemoveParams) -> ToolResult<Value> {
671 let output = cli::run(
672 ctx,
673 &metas::tailscale_switch_remove,
674 Invocation::mutate([
675 "switch".to_owned(),
676 "remove".to_owned(),
677 params.account.clone(),
678 ]),
679 )
680 .await?;
681 report(SwitchReport {
682 account: params.account,
683 outcome: "removed",
684 note: note(ctx, &output.stderr),
685 })
686}
687
688#[cfg(test)]
689mod tests {
690 use std::sync::Arc;
691
692 use serde_json::json;
693
694 use super::*;
695
696 use crate::meta::{Tier, Toolset};
697 use crate::testing::{Reply, StubBackend, context};
698
699 macro_rules! fixture {
701 ($name:literal) => {
702 include_str!(concat!("../../tests/fixtures/", $name))
703 };
704 }
705
706 const FAKE_KEY: &str = "tskey-auth-example-notarealkey";
709
710 async fn against<F, P, Fut>(reply: Reply, handler: F, params: P) -> (Value, Vec<Vec<String>>)
713 where
714 F: FnOnce(ToolContext, P) -> Fut,
715 Fut: Future<Output = ToolResult<Value>>,
716 {
717 let backend = Arc::new(StubBackend::always(reply));
718 let ctx = context(Arc::clone(&backend));
719 let value = handler(ctx, params).await.expect("the handler succeeds");
720 (value, backend.argv())
721 }
722
723 fn only(argv: &[Vec<String>]) -> &[String] {
725 assert_eq!(argv.len(), 1, "one command should have run: {argv:?}");
726 &argv[0]
727 }
728
729 #[tokio::test]
732 async fn setting_one_preference_names_only_that_preference() {
733 let (answer, argv) = against(
734 Reply::ok(""),
735 |ctx, p| async move { prefs_set(&ctx, p).await },
736 PrefsSetParams {
737 hostname: Some("workstation".to_owned()),
738 ..PrefsSetParams::default()
739 },
740 )
741 .await;
742 assert_eq!(only(&argv), ["set", "--hostname=workstation"]);
743 assert_eq!(answer["applied"], json!(["--hostname=workstation"]));
744 }
745
746 #[tokio::test]
747 async fn every_preference_that_was_given_is_passed_and_no_other() {
748 let (_, argv) = against(
749 Reply::ok(""),
750 |ctx, p| async move { prefs_set(&ctx, p).await },
751 PrefsSetParams {
752 accept_routes: Some(true),
753 advertise_routes: Some(vec![
754 "192.0.2.0/24".to_owned(),
755 "198.51.100.0/24".to_owned(),
756 ]),
757 exit_node: Some(String::new()),
758 auto_update: Some(false),
759 ..PrefsSetParams::default()
760 },
761 )
762 .await;
763 assert_eq!(
764 only(&argv),
765 [
766 "set",
767 "--accept-routes=true",
768 "--advertise-routes=192.0.2.0/24,198.51.100.0/24",
769 "--exit-node=",
770 "--auto-update=false",
771 ]
772 );
773 }
774
775 #[tokio::test]
776 async fn a_call_that_names_no_preference_is_refused_rather_than_run() {
777 let backend = Arc::new(StubBackend::always(Reply::ok("")));
778 let ctx = context(Arc::clone(&backend));
779 let error = prefs_set(&ctx, PrefsSetParams::default())
780 .await
781 .expect_err("nothing to change is a mistake, not a no-op");
782 assert_eq!(error.code, ErrorCode::InvalidArgs);
783 assert!(backend.argv().is_empty(), "nothing should have run");
784 }
785
786 #[tokio::test]
789 async fn a_preference_from_another_operating_system_is_refused_before_anything_runs() {
790 let backend = Arc::new(StubBackend::always(Reply::ok("")));
791 let ctx = context(Arc::clone(&backend));
792 let params = PrefsSetParams {
793 netfilter_mode: Some(NetfilterMode::Nodivert),
794 ..PrefsSetParams::default()
795 };
796 if cfg!(target_os = "linux") {
797 prefs_set(&ctx, params).await.expect("linux has netfilter");
799 assert!(
800 only(&backend.argv()).contains(&"--netfilter-mode=nodivert".to_owned()),
801 "{:?}",
802 backend.argv()
803 );
804 } else {
805 let error = prefs_set(&ctx, params)
806 .await
807 .expect_err("netfilter is a Linux preference");
808 assert_eq!(error.code, ErrorCode::UnsupportedPlatform);
809 assert!(
810 error.message.contains("netfilter_mode")
811 && error.message.contains(std::env::consts::OS),
812 "the answer should name the setting and the platform: {}",
813 error.message
814 );
815 assert!(backend.argv().is_empty(), "nothing should have run");
816 }
817 }
818
819 #[tokio::test]
822 async fn an_authentication_key_reaches_the_client_by_reference_not_by_value() {
823 let (_, argv) = against(
824 Reply::ok(fixture!("up-running.json")),
825 |ctx, p| async move { up(&ctx, p).await },
826 UpParams {
827 auth_key: Some(FAKE_KEY.to_owned()),
828 ..UpParams::default()
829 },
830 )
831 .await;
832 let args = only(&argv);
833 assert!(
834 !args.iter().any(|a| a.contains(FAKE_KEY)),
835 "the key must not be in the argument list: {args:?}"
836 );
837 assert!(
838 args.iter()
839 .any(|a| a.starts_with("--auth-key=file:") && a.ends_with(".key")),
840 "the key should be passed by file reference: {args:?}"
841 );
842 }
843
844 #[tokio::test]
845 async fn a_key_that_is_already_a_file_reference_is_passed_through() {
846 let (_, argv) = against(
847 Reply::ok(""),
848 |ctx, p| async move { login(&ctx, p).await },
849 LoginParams {
850 auth_key: Some("file:/run/secrets/tailscale.key".to_owned()),
851 ..LoginParams::default()
852 },
853 )
854 .await;
855 assert!(
856 only(&argv).contains(&"--auth-key=file:/run/secrets/tailscale.key".to_owned()),
857 "{argv:?}"
858 );
859 }
860
861 #[test]
862 fn a_secret_argument_carries_a_path_and_the_file_carries_the_secret() {
863 let (argument, file) = secret_argument("auth-key", FAKE_KEY).expect("a private file");
864 let file = file.expect("a literal key needs a file");
865 assert!(!argument.contains(FAKE_KEY), "{argument}");
866 assert_eq!(
867 std::fs::read_to_string(file.path()).expect("readable"),
868 FAKE_KEY
869 );
870 }
871
872 #[test]
875 fn a_wait_is_bounded_whatever_was_asked_for() {
876 assert_eq!(connect_timeouts(None).0, DEFAULT_CONNECT_TIMEOUT);
877 assert_eq!(connect_timeouts(Some(5)).0, 5);
878 assert_eq!(connect_timeouts(Some(0)).0, 1);
879 assert_eq!(connect_timeouts(Some(9_999)).0, MAX_CONNECT_TIMEOUT);
880 assert!(connect_timeouts(Some(60)).1 > Duration::from_secs(60));
883 }
884
885 #[tokio::test]
886 async fn connecting_bounds_its_wait_and_accepts_the_risk_it_was_confirmed_for() {
887 let (answer, argv) = against(
888 Reply::ok(fixture!("up-running.json")),
889 |ctx, p| async move { up(&ctx, p).await },
890 UpParams {
891 timeout_seconds: Some(9_999),
892 ..UpParams::default()
893 },
894 )
895 .await;
896 let args = only(&argv);
897 assert_eq!(args[0], "up");
898 assert!(args.contains(&"--json=true".to_owned()), "{args:?}");
899 assert!(args.contains(&"--timeout=300s".to_owned()), "{args:?}");
900 assert!(args.contains(&ACCEPTED_RISKS.to_owned()), "{args:?}");
901 assert_eq!(answer["timeout_seconds"], json!(MAX_CONNECT_TIMEOUT));
902 assert_eq!(answer["state"]["BackendState"], "Running");
903 }
904
905 #[tokio::test]
906 async fn reconnecting_passes_the_two_switches_that_make_it_destructive() {
907 let (_, argv) = against(
908 Reply::ok(fixture!("up-running.json")),
909 |ctx, p| async move { up(&ctx, p).await },
910 UpParams {
911 force_reauth: Some(true),
912 reset: Some(true),
913 ..UpParams::default()
914 },
915 )
916 .await;
917 let args = only(&argv);
918 assert!(args.contains(&"--force-reauth=true".to_owned()), "{args:?}");
919 assert!(args.contains(&"--reset=true".to_owned()), "{args:?}");
920 }
921
922 #[tokio::test]
925 async fn a_connect_that_needs_a_browser_hands_back_the_url() {
926 let (answer, _) = against(
927 Reply::ok(fixture!("up-needs-login.json")),
928 |ctx, p| async move { up(&ctx, p).await },
929 UpParams::default(),
930 )
931 .await;
932 assert_eq!(
933 answer["login_url"],
934 "https://login.example.com/a/0123456789abcdef"
935 );
936 assert_eq!(answer["state"]["BackendState"], "NeedsLogin");
937 }
938
939 #[tokio::test]
940 async fn a_login_hands_back_the_url_it_printed() {
941 let (answer, argv) = against(
942 Reply::ok(fixture!("login.txt")),
943 |ctx, p| async move { login(&ctx, p).await },
944 LoginParams {
945 nickname: Some("work".to_owned()),
946 advertise_tags: Some(vec!["tag:server".to_owned()]),
947 ..LoginParams::default()
948 },
949 )
950 .await;
951 assert_eq!(
952 answer["login_url"],
953 "https://login.example.com/a/0123456789abcdef"
954 );
955 let args = only(&argv);
956 assert!(
957 args.contains(&"--advertise-tags=tag:server".to_owned()),
958 "{args:?}"
959 );
960 assert!(args.contains(&"--nickname=work".to_owned()), "{args:?}");
961 assert!(args.contains(&"--timeout=60s".to_owned()), "{args:?}");
962 assert!(!args.contains(&ACCEPTED_RISKS.to_owned()), "{args:?}");
964 }
965
966 #[test]
967 fn a_url_is_found_wherever_the_client_put_it_and_nowhere_else() {
968 assert_eq!(
969 find_url("To authenticate, visit:\n\n\thttps://login.example.com/a/00\n"),
970 Some("https://login.example.com/a/00".to_owned())
971 );
972 assert_eq!(
973 find_url("visit https://login.example.com/a/00, then come back."),
974 Some("https://login.example.com/a/00".to_owned())
975 );
976 assert_eq!(find_url("Success.\n"), None);
977 }
978
979 #[tokio::test]
982 async fn disconnecting_accepts_the_risk_and_carries_a_reason_when_policy_wants_one() {
983 let (answer, argv) = against(
984 Reply::ok(""),
985 |ctx, p| async move { down(&ctx, p).await },
986 DownParams {
987 reason: Some("maintenance window".to_owned()),
988 },
989 )
990 .await;
991 assert_eq!(
992 only(&argv),
993 ["down", ACCEPTED_RISKS, "--reason=maintenance window"]
994 );
995 assert_eq!(answer["outcome"], "disconnected");
996 }
997
998 #[tokio::test]
999 async fn logging_out_carries_a_reason_but_no_risk_flag_the_command_lacks() {
1000 let (answer, argv) = against(
1001 Reply::ok(""),
1002 |ctx, p| async move { logout(&ctx, p).await },
1003 LogoutParams {
1004 reason: Some("decommissioned".to_owned()),
1005 },
1006 )
1007 .await;
1008 assert_eq!(only(&argv), ["logout", "--reason=decommissioned"]);
1009 assert_eq!(answer["outcome"], "logged out");
1010 }
1011
1012 #[tokio::test]
1015 async fn switching_and_forgetting_an_account_name_it_in_the_answer() {
1016 let (answer, argv) = against(
1017 Reply::ok(""),
1018 |ctx, p| async move { switch_profile(&ctx, p).await },
1019 SwitchParams {
1020 account: "example-tailnet.ts.net".to_owned(),
1021 },
1022 )
1023 .await;
1024 assert_eq!(only(&argv), ["switch", "example-tailnet.ts.net"]);
1025 assert_eq!(answer["account"], "example-tailnet.ts.net");
1026 assert_eq!(answer["outcome"], "switched");
1027
1028 let (answer, argv) = against(
1029 Reply::ok(""),
1030 |ctx, p| async move { switch_remove(&ctx, p).await },
1031 SwitchRemoveParams {
1032 account: "example-tailnet.ts.net".to_owned(),
1033 },
1034 )
1035 .await;
1036 assert_eq!(only(&argv), ["switch", "remove", "example-tailnet.ts.net"]);
1037 assert_eq!(answer["outcome"], "removed");
1038 }
1039
1040 #[tokio::test]
1043 async fn reading_preferences_asks_for_the_document() {
1044 let (answer, argv) = against(
1045 Reply::ok(fixture!("prefs.json")),
1046 |ctx, p| async move { prefs_get(&ctx, p).await },
1047 PrefsGetParams {
1048 setting: None,
1049 as_set_flags: false,
1050 },
1051 )
1052 .await;
1053 assert_eq!(only(&argv), ["get", "--json=true"]);
1054 assert_eq!(answer["preferences"]["WantRunning"], json!(true));
1055 assert!(answer["setting"].is_null());
1056 assert!(answer.get("set_flags").is_none());
1057 }
1058
1059 #[tokio::test]
1060 async fn one_preference_is_asked_for_the_way_the_client_spells_it() {
1061 let (answer, argv) = against(
1064 Reply::ok("{\"ShieldsUp\":false}"),
1065 |ctx, p| async move { prefs_get(&ctx, p).await },
1066 PrefsGetParams {
1067 setting: Some("shields_up".to_owned()),
1068 as_set_flags: false,
1069 },
1070 )
1071 .await;
1072 assert_eq!(only(&argv), ["get", "--json=true", "shields-up"]);
1073 assert_eq!(answer["setting"], "shields_up");
1074 }
1075
1076 #[tokio::test]
1077 async fn preferences_can_be_read_back_as_the_flags_that_would_set_them() {
1078 let (answer, argv) = against(
1079 Reply::ok(fixture!("prefs-set-flags.txt")),
1080 |ctx, p| async move { prefs_get(&ctx, p).await },
1081 PrefsGetParams {
1082 setting: None,
1083 as_set_flags: true,
1084 },
1085 )
1086 .await;
1087 assert_eq!(only(&argv), ["get", "--set-flags=true"]);
1088 assert!(
1089 answer["set_flags"]
1090 .as_str()
1091 .expect("flags")
1092 .starts_with("--accept-dns=true"),
1093 "{answer:#}"
1094 );
1095 assert!(answer.get("preferences").is_none());
1096 }
1097
1098 #[test]
1101 fn everything_that_can_cut_the_connection_asks_first() {
1102 for entry in entries() {
1103 let meta = &entry.meta;
1104 assert_eq!(meta.toolset, Toolset::LocalPrefs, "{}", meta.name);
1105 let severing = matches!(
1106 meta.name,
1107 "tailscale_up"
1108 | "tailscale_down"
1109 | "tailscale_login"
1110 | "tailscale_logout"
1111 | "tailscale_switch_profile"
1112 );
1113 assert_eq!(
1114 meta.self_severing, severing,
1115 "`{}` is on the wrong side of the self-severing line",
1116 meta.name
1117 );
1118 assert_eq!(
1121 meta.requires_confirmation,
1122 meta.tier != Tier::Read && meta.name != "tailscale_prefs_set",
1123 "`{}` confirms the wrong way round",
1124 meta.name
1125 );
1126 }
1127 }
1128}