use std::time::Duration;
use rmcp::schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tailscale_cli::{Invocation, SecretFile};
use crate::cli;
use crate::context::ToolContext;
use crate::error::{ErrorCode, ToolError, ToolResult};
use crate::tools::common::{
bounded_wait, find_url, flag, note, only_on, push_bool, push_list, push_text, report,
secret_value,
};
crate::tools! {
tailscale_prefs_get => PrefsGetParams, prefs_get,
toolset: LocalPrefs, tier: Read, idempotent: true, since: "1.90";
tailscale_prefs_set => PrefsSetParams, prefs_set,
toolset: LocalPrefs, tier: Write, idempotent: true;
tailscale_up => UpParams, up,
toolset: LocalPrefs, tier: Destructive, severing: true;
tailscale_down => DownParams, down,
toolset: LocalPrefs, tier: Destructive, severing: true;
tailscale_login => LoginParams, login,
toolset: LocalPrefs, tier: Write, severing: true;
tailscale_logout => LogoutParams, logout,
toolset: LocalPrefs, tier: Destructive, severing: true;
tailscale_switch_profile => SwitchParams, switch_profile,
toolset: LocalPrefs, tier: Write, severing: true;
tailscale_switch_remove => SwitchRemoveParams, switch_remove,
toolset: LocalPrefs, tier: Destructive, confirm: true;
}
const DEFAULT_CONNECT_TIMEOUT: u64 = 60;
const MAX_CONNECT_TIMEOUT: u64 = 300;
const ACCEPTED_RISKS: &str = "--accept-risk=all";
const LINUX_ONLY: &[&str] = &["linux"];
const WINDOWS_ONLY: &[&str] = &["windows"];
#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum NetfilterMode {
On,
Nodivert,
Off,
}
impl NetfilterMode {
const fn as_str(self) -> &'static str {
match self {
Self::On => "on",
Self::Nodivert => "nodivert",
Self::Off => "off",
}
}
}
macro_rules! prefs_params {
(
$(#[doc = $doc:literal])*
$name:ident { $( $(#[doc = $field_doc:literal])* $field:ident : $ty:ty ),* $(,)? }
) => {
$(#[doc = $doc])*
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct $name {
/// Accept the DNS configuration the tailnet publishes, including
#[serde(default)]
pub accept_dns: Option<bool>,
#[serde(default)]
pub accept_routes: Option<bool>,
#[serde(default)]
pub advertise_connector: Option<bool>,
#[serde(default)]
pub advertise_exit_node: Option<bool>,
#[serde(default)]
pub advertise_routes: Option<Vec<String>>,
#[serde(default)]
pub exit_node: Option<String>,
#[serde(default)]
pub exit_node_allow_lan_access: Option<bool>,
#[serde(default)]
pub hostname: Option<String>,
#[serde(default)]
pub operator: Option<String>,
#[serde(default)]
pub report_posture: Option<bool>,
#[serde(default)]
pub shields_up: Option<bool>,
#[serde(default)]
pub ssh: Option<bool>,
#[serde(default)]
pub snat_subnet_routes: Option<bool>,
#[serde(default)]
pub stateful_filtering: Option<bool>,
#[serde(default)]
pub netfilter_mode: Option<NetfilterMode>,
#[serde(default)]
pub unattended: Option<bool>,
$(
$(#[doc = $field_doc])*
#[serde(default)]
pub $field: $ty,
)*
}
impl $name {
fn shared_flags(&self) -> Vec<String> {
let mut args = Vec::new();
push_bool(&mut args, "accept-dns", self.accept_dns);
push_bool(&mut args, "accept-routes", self.accept_routes);
push_bool(&mut args, "advertise-connector", self.advertise_connector);
push_bool(&mut args, "advertise-exit-node", self.advertise_exit_node);
push_list(&mut args, "advertise-routes", self.advertise_routes.as_deref());
push_text(&mut args, "exit-node", self.exit_node.as_deref());
push_bool(
&mut args,
"exit-node-allow-lan-access",
self.exit_node_allow_lan_access,
);
push_text(&mut args, "hostname", self.hostname.as_deref());
push_text(&mut args, "operator", self.operator.as_deref());
push_bool(&mut args, "report-posture", self.report_posture);
push_bool(&mut args, "shields-up", self.shields_up);
push_bool(&mut args, "ssh", self.ssh);
args
}
fn platform_flags(&self) -> ToolResult<Vec<String>> {
let mut args = Vec::new();
if let Some(value) = self.snat_subnet_routes {
only_on("snat_subnet_routes", LINUX_ONLY)?;
args.push(flag("snat-subnet-routes", value));
}
if let Some(value) = self.stateful_filtering {
only_on("stateful_filtering", LINUX_ONLY)?;
args.push(flag("stateful-filtering", value));
}
if let Some(mode) = self.netfilter_mode {
only_on("netfilter_mode", LINUX_ONLY)?;
args.push(format!("--netfilter-mode={}", mode.as_str()));
}
if let Some(value) = self.unattended {
only_on("unattended", WINDOWS_ONLY)?;
args.push(flag("unattended", value));
}
Ok(args)
}
}
};
}
prefs_params! {
PrefsSetParams {
auto_update: Option<bool>,
update_check: Option<bool>,
webclient: Option<bool>,
nickname: Option<String>,
relay_server_port: Option<String>,
relay_server_static_endpoints: Option<Vec<String>>,
}
}
prefs_params! {
UpParams {
advertise_tags: Option<Vec<String>>,
auth_key: Option<String>,
login_server: Option<String>,
timeout_seconds: Option<u64>,
force_reauth: Option<bool>,
reset: Option<bool>,
}
}
prefs_params! {
LoginParams {
advertise_tags: Option<Vec<String>>,
auth_key: Option<String>,
login_server: Option<String>,
nickname: Option<String>,
timeout_seconds: Option<u64>,
}
}
fn connect_timeouts(requested: Option<u64>) -> (u64, Duration) {
bounded_wait(requested, DEFAULT_CONNECT_TIMEOUT, MAX_CONNECT_TIMEOUT)
}
fn secret_argument(name: &str, value: &str) -> ToolResult<(String, Option<SecretFile>)> {
let (value, file) = secret_value(name, value)?;
Ok((format!("--{name}={value}"), file))
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct PrefsGetParams {
#[serde(default)]
pub setting: Option<String>,
#[serde(default)]
pub as_set_flags: bool,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct PrefsReport {
pub setting: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preferences: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub set_flags: Option<String>,
}
async fn prefs_get(ctx: &ToolContext, params: PrefsGetParams) -> ToolResult<Value> {
let meta = &metas::tailscale_prefs_get;
let mut args = vec!["get".to_owned()];
args.push(flag(
if params.as_set_flags {
"set-flags"
} else {
"json"
},
true,
));
if let Some(setting) = ¶ms.setting {
args.push(setting.replace('_', "-"));
}
let text = cli::run_text(ctx, meta, Invocation::read(args)).await?;
if params.as_set_flags {
return report(PrefsReport {
setting: params.setting,
preferences: None,
set_flags: Some(text.trim().to_owned()),
});
}
let preferences = serde_json::from_str::<Value>(text.trim()).map_err(|e| {
ToolError::new(
ErrorCode::CliFailed,
format!("`tailscale get` did not print JSON: {e}"),
)
})?;
report(PrefsReport {
setting: params.setting,
preferences: Some(preferences),
set_flags: None,
})
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct PrefsSetReport {
pub applied: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
async fn prefs_set(ctx: &ToolContext, params: PrefsSetParams) -> ToolResult<Value> {
let meta = &metas::tailscale_prefs_set;
let mut flags = params.shared_flags();
flags.extend(params.platform_flags()?);
push_bool(&mut flags, "auto-update", params.auto_update);
push_bool(&mut flags, "update-check", params.update_check);
push_bool(&mut flags, "webclient", params.webclient);
push_text(&mut flags, "nickname", params.nickname.as_deref());
push_text(
&mut flags,
"relay-server-port",
params.relay_server_port.as_deref(),
);
push_list(
&mut flags,
"relay-server-static-endpoints",
params.relay_server_static_endpoints.as_deref(),
);
if flags.is_empty() {
return Err(ToolError::invalid_args(
"name at least one preference to change; `tailscale_prefs_set` \
changes only what it is given",
));
}
let mut args = vec!["set".to_owned()];
args.extend(flags.iter().cloned());
let output = cli::run(ctx, meta, Invocation::mutate(args)).await?;
report(PrefsSetReport {
applied: flags,
note: note(ctx, &output.stderr),
})
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct ConnectReport {
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub login_url: Option<String>,
pub timeout_seconds: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
async fn up(ctx: &ToolContext, params: UpParams) -> ToolResult<Value> {
let meta = &metas::tailscale_up;
let (seconds, budget) = connect_timeouts(params.timeout_seconds);
let mut args = vec!["up".to_owned(), flag("json", true)];
args.extend(params.shared_flags());
args.extend(params.platform_flags()?);
push_list(
&mut args,
"advertise-tags",
params.advertise_tags.as_deref(),
);
push_text(&mut args, "login-server", params.login_server.as_deref());
push_bool(&mut args, "force-reauth", params.force_reauth);
push_bool(&mut args, "reset", params.reset);
args.push(format!("--timeout={seconds}s"));
args.push(ACCEPTED_RISKS.to_owned());
let mut key = None;
if let Some(value) = ¶ms.auth_key {
let (argument, file) = secret_argument("auth-key", value)?;
args.push(argument);
key = file;
}
let output = cli::run(ctx, meta, Invocation::mutate(args).with_timeout(budget)).await?;
drop(key);
let stdout = output.stdout_str();
let state = serde_json::from_str::<Value>(stdout.trim()).ok();
let login_url = state
.as_ref()
.and_then(|s| s["AuthURL"].as_str().map(str::to_owned))
.filter(|url| !url.is_empty())
.or_else(|| find_url(&stdout));
report(ConnectReport {
state,
login_url,
timeout_seconds: seconds,
note: note(ctx, &output.stderr),
})
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DownParams {
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct DisconnectReport {
pub outcome: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
async fn down(ctx: &ToolContext, params: DownParams) -> ToolResult<Value> {
let mut args = vec!["down".to_owned(), ACCEPTED_RISKS.to_owned()];
push_text(&mut args, "reason", params.reason.as_deref());
let output = cli::run(ctx, &metas::tailscale_down, Invocation::mutate(args)).await?;
report(DisconnectReport {
outcome: "disconnected",
note: note(ctx, &output.stderr),
})
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct LoginReport {
#[serde(skip_serializing_if = "Option::is_none")]
pub login_url: Option<String>,
pub timeout_seconds: u64,
pub output: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
async fn login(ctx: &ToolContext, params: LoginParams) -> ToolResult<Value> {
let meta = &metas::tailscale_login;
let (seconds, budget) = connect_timeouts(params.timeout_seconds);
let mut args = vec!["login".to_owned()];
args.extend(params.shared_flags());
args.extend(params.platform_flags()?);
push_list(
&mut args,
"advertise-tags",
params.advertise_tags.as_deref(),
);
push_text(&mut args, "login-server", params.login_server.as_deref());
push_text(&mut args, "nickname", params.nickname.as_deref());
args.push(format!("--timeout={seconds}s"));
let mut key = None;
if let Some(value) = ¶ms.auth_key {
let (argument, file) = secret_argument("auth-key", value)?;
args.push(argument);
key = file;
}
let output = cli::run(ctx, meta, Invocation::mutate(args).with_timeout(budget)).await?;
drop(key);
let printed = ctx.redactor.apply(&output.stdout_str()).trim().to_owned();
report(LoginReport {
login_url: find_url(&printed),
timeout_seconds: seconds,
output: printed,
note: note(ctx, &output.stderr),
})
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct LogoutParams {
#[serde(default)]
pub reason: Option<String>,
}
async fn logout(ctx: &ToolContext, params: LogoutParams) -> ToolResult<Value> {
let mut args = vec!["logout".to_owned()];
push_text(&mut args, "reason", params.reason.as_deref());
let output = cli::run(ctx, &metas::tailscale_logout, Invocation::mutate(args)).await?;
report(DisconnectReport {
outcome: "logged out",
note: note(ctx, &output.stderr),
})
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SwitchParams {
pub account: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct SwitchReport {
pub account: String,
pub outcome: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
async fn switch_profile(ctx: &ToolContext, params: SwitchParams) -> ToolResult<Value> {
let output = cli::run(
ctx,
&metas::tailscale_switch_profile,
Invocation::mutate(["switch".to_owned(), params.account.clone()]),
)
.await?;
report(SwitchReport {
account: params.account,
outcome: "switched",
note: note(ctx, &output.stderr),
})
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SwitchRemoveParams {
pub account: String,
}
async fn switch_remove(ctx: &ToolContext, params: SwitchRemoveParams) -> ToolResult<Value> {
let output = cli::run(
ctx,
&metas::tailscale_switch_remove,
Invocation::mutate([
"switch".to_owned(),
"remove".to_owned(),
params.account.clone(),
]),
)
.await?;
report(SwitchReport {
account: params.account,
outcome: "removed",
note: note(ctx, &output.stderr),
})
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use serde_json::json;
use super::*;
use crate::meta::{Tier, Toolset};
use crate::testing::{Reply, StubBackend, context};
macro_rules! fixture {
($name:literal) => {
include_str!(concat!("../../tests/fixtures/", $name))
};
}
const FAKE_KEY: &str = "tskey-auth-example-notarealkey";
async fn against<F, P, Fut>(reply: Reply, handler: F, params: P) -> (Value, Vec<Vec<String>>)
where
F: FnOnce(ToolContext, P) -> Fut,
Fut: Future<Output = ToolResult<Value>>,
{
let backend = Arc::new(StubBackend::always(reply));
let ctx = context(Arc::clone(&backend));
let value = handler(ctx, params).await.expect("the handler succeeds");
(value, backend.argv())
}
fn only(argv: &[Vec<String>]) -> &[String] {
assert_eq!(argv.len(), 1, "one command should have run: {argv:?}");
&argv[0]
}
#[tokio::test]
async fn setting_one_preference_names_only_that_preference() {
let (answer, argv) = against(
Reply::ok(""),
|ctx, p| async move { prefs_set(&ctx, p).await },
PrefsSetParams {
hostname: Some("workstation".to_owned()),
..PrefsSetParams::default()
},
)
.await;
assert_eq!(only(&argv), ["set", "--hostname=workstation"]);
assert_eq!(answer["applied"], json!(["--hostname=workstation"]));
}
#[tokio::test]
async fn every_preference_that_was_given_is_passed_and_no_other() {
let (_, argv) = against(
Reply::ok(""),
|ctx, p| async move { prefs_set(&ctx, p).await },
PrefsSetParams {
accept_routes: Some(true),
advertise_routes: Some(vec![
"192.0.2.0/24".to_owned(),
"198.51.100.0/24".to_owned(),
]),
exit_node: Some(String::new()),
auto_update: Some(false),
..PrefsSetParams::default()
},
)
.await;
assert_eq!(
only(&argv),
[
"set",
"--accept-routes=true",
"--advertise-routes=192.0.2.0/24,198.51.100.0/24",
"--exit-node=",
"--auto-update=false",
]
);
}
#[tokio::test]
async fn a_call_that_names_no_preference_is_refused_rather_than_run() {
let backend = Arc::new(StubBackend::always(Reply::ok("")));
let ctx = context(Arc::clone(&backend));
let error = prefs_set(&ctx, PrefsSetParams::default())
.await
.expect_err("nothing to change is a mistake, not a no-op");
assert_eq!(error.code, ErrorCode::InvalidArgs);
assert!(backend.argv().is_empty(), "nothing should have run");
}
#[tokio::test]
async fn a_preference_from_another_operating_system_is_refused_before_anything_runs() {
let backend = Arc::new(StubBackend::always(Reply::ok("")));
let ctx = context(Arc::clone(&backend));
let params = PrefsSetParams {
netfilter_mode: Some(NetfilterMode::Nodivert),
..PrefsSetParams::default()
};
if cfg!(target_os = "linux") {
prefs_set(&ctx, params).await.expect("linux has netfilter");
assert!(
only(&backend.argv()).contains(&"--netfilter-mode=nodivert".to_owned()),
"{:?}",
backend.argv()
);
} else {
let error = prefs_set(&ctx, params)
.await
.expect_err("netfilter is a Linux preference");
assert_eq!(error.code, ErrorCode::UnsupportedPlatform);
assert!(
error.message.contains("netfilter_mode")
&& error.message.contains(std::env::consts::OS),
"the answer should name the setting and the platform: {}",
error.message
);
assert!(backend.argv().is_empty(), "nothing should have run");
}
}
#[tokio::test]
async fn an_authentication_key_reaches_the_client_by_reference_not_by_value() {
let (_, argv) = against(
Reply::ok(fixture!("up-running.json")),
|ctx, p| async move { up(&ctx, p).await },
UpParams {
auth_key: Some(FAKE_KEY.to_owned()),
..UpParams::default()
},
)
.await;
let args = only(&argv);
assert!(
!args.iter().any(|a| a.contains(FAKE_KEY)),
"the key must not be in the argument list: {args:?}"
);
assert!(
args.iter()
.any(|a| a.starts_with("--auth-key=file:") && a.ends_with(".key")),
"the key should be passed by file reference: {args:?}"
);
}
#[tokio::test]
async fn a_key_that_is_already_a_file_reference_is_passed_through() {
let (_, argv) = against(
Reply::ok(""),
|ctx, p| async move { login(&ctx, p).await },
LoginParams {
auth_key: Some("file:/run/secrets/tailscale.key".to_owned()),
..LoginParams::default()
},
)
.await;
assert!(
only(&argv).contains(&"--auth-key=file:/run/secrets/tailscale.key".to_owned()),
"{argv:?}"
);
}
#[test]
fn a_secret_argument_carries_a_path_and_the_file_carries_the_secret() {
let (argument, file) = secret_argument("auth-key", FAKE_KEY).expect("a private file");
let file = file.expect("a literal key needs a file");
assert!(!argument.contains(FAKE_KEY), "{argument}");
assert_eq!(
std::fs::read_to_string(file.path()).expect("readable"),
FAKE_KEY
);
}
#[test]
fn a_wait_is_bounded_whatever_was_asked_for() {
assert_eq!(connect_timeouts(None).0, DEFAULT_CONNECT_TIMEOUT);
assert_eq!(connect_timeouts(Some(5)).0, 5);
assert_eq!(connect_timeouts(Some(0)).0, 1);
assert_eq!(connect_timeouts(Some(9_999)).0, MAX_CONNECT_TIMEOUT);
assert!(connect_timeouts(Some(60)).1 > Duration::from_secs(60));
}
#[tokio::test]
async fn connecting_bounds_its_wait_and_accepts_the_risk_it_was_confirmed_for() {
let (answer, argv) = against(
Reply::ok(fixture!("up-running.json")),
|ctx, p| async move { up(&ctx, p).await },
UpParams {
timeout_seconds: Some(9_999),
..UpParams::default()
},
)
.await;
let args = only(&argv);
assert_eq!(args[0], "up");
assert!(args.contains(&"--json=true".to_owned()), "{args:?}");
assert!(args.contains(&"--timeout=300s".to_owned()), "{args:?}");
assert!(args.contains(&ACCEPTED_RISKS.to_owned()), "{args:?}");
assert_eq!(answer["timeout_seconds"], json!(MAX_CONNECT_TIMEOUT));
assert_eq!(answer["state"]["BackendState"], "Running");
}
#[tokio::test]
async fn reconnecting_passes_the_two_switches_that_make_it_destructive() {
let (_, argv) = against(
Reply::ok(fixture!("up-running.json")),
|ctx, p| async move { up(&ctx, p).await },
UpParams {
force_reauth: Some(true),
reset: Some(true),
..UpParams::default()
},
)
.await;
let args = only(&argv);
assert!(args.contains(&"--force-reauth=true".to_owned()), "{args:?}");
assert!(args.contains(&"--reset=true".to_owned()), "{args:?}");
}
#[tokio::test]
async fn a_connect_that_needs_a_browser_hands_back_the_url() {
let (answer, _) = against(
Reply::ok(fixture!("up-needs-login.json")),
|ctx, p| async move { up(&ctx, p).await },
UpParams::default(),
)
.await;
assert_eq!(
answer["login_url"],
"https://login.example.com/a/0123456789abcdef"
);
assert_eq!(answer["state"]["BackendState"], "NeedsLogin");
}
#[tokio::test]
async fn a_login_hands_back_the_url_it_printed() {
let (answer, argv) = against(
Reply::ok(fixture!("login.txt")),
|ctx, p| async move { login(&ctx, p).await },
LoginParams {
nickname: Some("work".to_owned()),
advertise_tags: Some(vec!["tag:server".to_owned()]),
..LoginParams::default()
},
)
.await;
assert_eq!(
answer["login_url"],
"https://login.example.com/a/0123456789abcdef"
);
let args = only(&argv);
assert!(
args.contains(&"--advertise-tags=tag:server".to_owned()),
"{args:?}"
);
assert!(args.contains(&"--nickname=work".to_owned()), "{args:?}");
assert!(args.contains(&"--timeout=60s".to_owned()), "{args:?}");
assert!(!args.contains(&ACCEPTED_RISKS.to_owned()), "{args:?}");
}
#[test]
fn a_url_is_found_wherever_the_client_put_it_and_nowhere_else() {
assert_eq!(
find_url("To authenticate, visit:\n\n\thttps://login.example.com/a/00\n"),
Some("https://login.example.com/a/00".to_owned())
);
assert_eq!(
find_url("visit https://login.example.com/a/00, then come back."),
Some("https://login.example.com/a/00".to_owned())
);
assert_eq!(find_url("Success.\n"), None);
}
#[tokio::test]
async fn disconnecting_accepts_the_risk_and_carries_a_reason_when_policy_wants_one() {
let (answer, argv) = against(
Reply::ok(""),
|ctx, p| async move { down(&ctx, p).await },
DownParams {
reason: Some("maintenance window".to_owned()),
},
)
.await;
assert_eq!(
only(&argv),
["down", ACCEPTED_RISKS, "--reason=maintenance window"]
);
assert_eq!(answer["outcome"], "disconnected");
}
#[tokio::test]
async fn logging_out_carries_a_reason_but_no_risk_flag_the_command_lacks() {
let (answer, argv) = against(
Reply::ok(""),
|ctx, p| async move { logout(&ctx, p).await },
LogoutParams {
reason: Some("decommissioned".to_owned()),
},
)
.await;
assert_eq!(only(&argv), ["logout", "--reason=decommissioned"]);
assert_eq!(answer["outcome"], "logged out");
}
#[tokio::test]
async fn switching_and_forgetting_an_account_name_it_in_the_answer() {
let (answer, argv) = against(
Reply::ok(""),
|ctx, p| async move { switch_profile(&ctx, p).await },
SwitchParams {
account: "example-tailnet.ts.net".to_owned(),
},
)
.await;
assert_eq!(only(&argv), ["switch", "example-tailnet.ts.net"]);
assert_eq!(answer["account"], "example-tailnet.ts.net");
assert_eq!(answer["outcome"], "switched");
let (answer, argv) = against(
Reply::ok(""),
|ctx, p| async move { switch_remove(&ctx, p).await },
SwitchRemoveParams {
account: "example-tailnet.ts.net".to_owned(),
},
)
.await;
assert_eq!(only(&argv), ["switch", "remove", "example-tailnet.ts.net"]);
assert_eq!(answer["outcome"], "removed");
}
#[tokio::test]
async fn reading_preferences_asks_for_the_document() {
let (answer, argv) = against(
Reply::ok(fixture!("prefs.json")),
|ctx, p| async move { prefs_get(&ctx, p).await },
PrefsGetParams {
setting: None,
as_set_flags: false,
},
)
.await;
assert_eq!(only(&argv), ["get", "--json=true"]);
assert_eq!(answer["preferences"]["WantRunning"], json!(true));
assert!(answer["setting"].is_null());
assert!(answer.get("set_flags").is_none());
}
#[tokio::test]
async fn one_preference_is_asked_for_the_way_the_client_spells_it() {
let (answer, argv) = against(
Reply::ok("{\"ShieldsUp\":false}"),
|ctx, p| async move { prefs_get(&ctx, p).await },
PrefsGetParams {
setting: Some("shields_up".to_owned()),
as_set_flags: false,
},
)
.await;
assert_eq!(only(&argv), ["get", "--json=true", "shields-up"]);
assert_eq!(answer["setting"], "shields_up");
}
#[tokio::test]
async fn preferences_can_be_read_back_as_the_flags_that_would_set_them() {
let (answer, argv) = against(
Reply::ok(fixture!("prefs-set-flags.txt")),
|ctx, p| async move { prefs_get(&ctx, p).await },
PrefsGetParams {
setting: None,
as_set_flags: true,
},
)
.await;
assert_eq!(only(&argv), ["get", "--set-flags=true"]);
assert!(
answer["set_flags"]
.as_str()
.expect("flags")
.starts_with("--accept-dns=true"),
"{answer:#}"
);
assert!(answer.get("preferences").is_none());
}
#[test]
fn everything_that_can_cut_the_connection_asks_first() {
for entry in entries() {
let meta = &entry.meta;
assert_eq!(meta.toolset, Toolset::LocalPrefs, "{}", meta.name);
let severing = matches!(
meta.name,
"tailscale_up"
| "tailscale_down"
| "tailscale_login"
| "tailscale_logout"
| "tailscale_switch_profile"
);
assert_eq!(
meta.self_severing, severing,
"`{}` is on the wrong side of the self-severing line",
meta.name
);
assert_eq!(
meta.requires_confirmation,
meta.tier != Tier::Read && meta.name != "tailscale_prefs_set",
"`{}` confirms the wrong way round",
meta.name
);
}
}
}