Skip to main content

tailscale_mcp/tools/
local_prefs.rs

1//! Changing what the local node is and where it is connected.
2//!
3//! Eight commands, and one idea running through them: the node this server
4//! runs on is very often the node the caller is reaching us over. Every tool
5//! here can end that conversation — `down` and `logout` by design, `up` and
6//! `login` by re-authenticating or by restating a preference set that omits
7//! something, `switch` by changing which account the connection belongs to. So
8//! all but the two that only read are self-severing and refuse without
9//! `confirm: true`, and the CLI's own risk acceptance is passed only on a call
10//! that carried one (DECISIONS Q17).
11//!
12//! The division of labour between `tailscale_prefs_set` and `tailscale_up` is
13//! the other thing to know. `up` applies a whole preference set: anything not
14//! restated goes back to its default, which is why it is destructive and why
15//! its description sends a caller who only wants to change one thing to
16//! `tailscale_prefs_set`. `set` changes exactly the preferences it is given and
17//! nothing else.
18
19use 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    /// Show the node's current preferences: one setting by name, or all of
36    /// them. Reads only; use `tailscale_prefs_set` to change anything.
37    tailscale_prefs_get => PrefsGetParams, prefs_get,
38        toolset: LocalPrefs, tier: Read, idempotent: true, since: "1.90";
39
40    /// Change only the preferences named here, leaving every other preference
41    /// as it is. This is the tool to reach for on a node that is already
42    /// connected: unlike `tailscale_up` it does not restate the whole
43    /// preference set, so it cannot reset something by omitting it.
44    tailscale_prefs_set => PrefsSetParams, prefs_set,
45        toolset: LocalPrefs, tier: Write, idempotent: true;
46
47    /// Connect this node to the tailnet, logging in if it is not already, and
48    /// apply the preferences given here.
49    ///
50    /// This applies a *whole* preference set: anything not restated goes back
51    /// to its default. On a node that is already connected, use
52    /// `tailscale_prefs_set` instead unless you mean to reset the rest. Without
53    /// an authentication key the client prints a login URL and waits, so the
54    /// call is bounded by `timeout_seconds` and returns the URL when it has
55    /// one.
56    tailscale_up => UpParams, up,
57        toolset: LocalPrefs, tier: Destructive, severing: true;
58
59    /// Disconnect this node from the tailnet. The node stays logged in and can
60    /// be reconnected with `tailscale_up`.
61    tailscale_down => DownParams, down,
62        toolset: LocalPrefs, tier: Destructive, severing: true;
63
64    /// Log in, adding a new account profile if this node is already logged in
65    /// to another. Prints a login URL and waits when no authentication key is
66    /// given, bounded by `timeout_seconds`.
67    tailscale_login => LoginParams, login,
68        toolset: LocalPrefs, tier: Write, severing: true;
69
70    /// Log out and expire this node's key. The node disconnects, and coming
71    /// back needs a fresh login rather than a reconnect.
72    tailscale_logout => LogoutParams, logout,
73        toolset: LocalPrefs, tier: Destructive, severing: true;
74
75    /// Switch this machine to one of the other accounts stored on it, which
76    /// restarts the connection under that account's identity. Use
77    /// `tailscale_switch_list` to see the accounts and their ids.
78    tailscale_switch_profile => SwitchParams, switch_profile,
79        toolset: LocalPrefs, tier: Write, severing: true;
80
81    /// Forget one of the accounts stored on this machine. The account itself is
82    /// untouched; it is logging in again that brings it back.
83    tailscale_switch_remove => SwitchRemoveParams, switch_remove,
84        toolset: LocalPrefs, tier: Destructive, confirm: true;
85}
86
87// ---------------------------------------------------------------------------
88// Bounds
89// ---------------------------------------------------------------------------
90
91/// How long a connect or a login waits by default.
92const DEFAULT_CONNECT_TIMEOUT: u64 = 60;
93/// The longest either may be asked to wait. The CLI's own default is to wait
94/// for ever, which an agent cannot recover from.
95const MAX_CONNECT_TIMEOUT: u64 = 300;
96/// The risks the CLI asks about before it disconnects something: losing an SSH
97/// session it is carrying, or taking down a macOS app connector. A tool that
98/// requires `confirm: true` has already asked the caller that question, so the
99/// answer is passed on rather than left for a prompt nobody can see.
100const ACCEPTED_RISKS: &str = "--accept-risk=all";
101
102// ---------------------------------------------------------------------------
103// Preference parameters
104// ---------------------------------------------------------------------------
105
106/// The operating systems each platform-specific preference exists on.
107const LINUX_ONLY: &[&str] = &["linux"];
108const WINDOWS_ONLY: &[&str] = &["windows"];
109
110/// How Tailscale manages the Linux firewall.
111#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)]
112#[serde(rename_all = "snake_case")]
113pub enum NetfilterMode {
114    /// Tailscale manages its own netfilter rules.
115    On,
116    /// Tailscale adds rules but does not divert traffic into them.
117    Nodivert,
118    /// Tailscale manages nothing; the rules are yours to write.
119    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
132/// Declare a parameter struct carrying the preferences that `set`, `up` and
133/// `login` all understand, plus whatever else that command takes.
134///
135/// A macro rather than a shared struct behind `#[serde(flatten)]` because a
136/// flattened schema is a composition rather than a plain object, and every one
137/// of these tools is better served by a schema a client can read as a list of
138/// named settings.
139macro_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            /// Accept the DNS configuration the tailnet publishes, including
148            /// MagicDNS.
149            #[serde(default)]
150            pub accept_dns: Option<bool>,
151            /// Accept the subnet routes other nodes advertise.
152            #[serde(default)]
153            pub accept_routes: Option<bool>,
154            /// Advertise this node as an app connector.
155            #[serde(default)]
156            pub advertise_connector: Option<bool>,
157            /// Offer this node to the tailnet as an exit node. It still has to
158            /// be approved in the admin console before anyone can use it.
159            #[serde(default)]
160            pub advertise_exit_node: Option<bool>,
161            /// The subnet routes this node offers, as CIDR blocks. An empty
162            /// list withdraws every route it was advertising.
163            #[serde(default)]
164            pub advertise_routes: Option<Vec<String>>,
165            /// The exit node to send traffic through: an address, a base
166            /// hostname, or `auto:any` to let the client choose. An empty
167            /// string stops using one.
168            #[serde(default)]
169            pub exit_node: Option<String>,
170            /// Keep reaching the local network directly while an exit node is
171            /// in use.
172            #[serde(default)]
173            pub exit_node_allow_lan_access: Option<bool>,
174            /// The name this node goes by on the tailnet, overriding the
175            /// machine's own hostname.
176            #[serde(default)]
177            pub hostname: Option<String>,
178            /// The Unix user allowed to run `tailscale` without elevation.
179            #[serde(default)]
180            pub operator: Option<String>,
181            /// Allow device posture information to be collected from this node.
182            #[serde(default)]
183            pub report_posture: Option<bool>,
184            /// Block all incoming connections from the tailnet.
185            #[serde(default)]
186            pub shields_up: Option<bool>,
187            /// Run the Tailscale SSH server on this node.
188            #[serde(default)]
189            pub ssh: Option<bool>,
190            /// Linux only. Source-NAT traffic arriving for an advertised
191            /// subnet route.
192            #[serde(default)]
193            pub snat_subnet_routes: Option<bool>,
194            /// Linux only. Drop forwarded traffic that does not belong to a
195            /// known connection.
196            #[serde(default)]
197            pub stateful_filtering: Option<bool>,
198            /// Linux only. How much of the firewall Tailscale manages.
199            #[serde(default)]
200            pub netfilter_mode: Option<NetfilterMode>,
201            /// Windows only. Keep the tailnet connection up when no user is
202            /// signed in.
203            #[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            /// The flags for the preferences every one of these commands takes.
214            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            /// The flags for the preferences that exist on one platform only,
236            /// refused here rather than by a binary that has never heard of
237            /// them.
238            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    /// The preferences to change, and nothing else.
264    PrefsSetParams {
265        /// Apply Tailscale updates automatically as they are released.
266        auto_update: Option<bool>,
267        /// Be told when an update is available.
268        update_check: Option<bool>,
269        /// Serve the node's own web interface to the tailnet on port 5252.
270        webclient: Option<bool>,
271        /// A short name for this account profile on this machine.
272        nickname: Option<String>,
273        /// The UDP port this node listens on as a peer relay. `0` picks one at
274        /// random, and an empty string turns the relay off.
275        relay_server_port: Option<String>,
276        /// Fixed `address:port` endpoints to advertise as a peer relay, for a
277        /// node whose own view of its address is wrong.
278        relay_server_static_endpoints: Option<Vec<String>>,
279    }
280}
281
282prefs_params! {
283    /// The whole preference set to connect with. Anything left out returns to
284    /// its default.
285    UpParams {
286        /// ACL tags to claim for this node, with or without the `tag:` prefix.
287        advertise_tags: Option<Vec<String>>,
288        /// An authentication key, so the node can log in without a browser.
289        /// Either the key itself or `file:<path>` to read it from a file. A key
290        /// given here is written to a private temporary file and passed by
291        /// reference: it never appears in the command line.
292        auth_key: Option<String>,
293        /// A control server other than Tailscale's own.
294        login_server: Option<String>,
295        /// Seconds to wait for the node to come up before giving up. Capped at
296        /// 300; the client's own default is to wait for ever.
297        timeout_seconds: Option<u64>,
298        /// Log in again even though the node is already authenticated. Drops
299        /// the connection while it happens.
300        force_reauth: Option<bool>,
301        /// Return every preference not named here to its default, rather than
302        /// leaving the client to complain that they were not restated.
303        reset: Option<bool>,
304    }
305}
306
307prefs_params! {
308    /// The identity to log in as, and the preferences to log in with.
309    LoginParams {
310        /// ACL tags to claim for this node, with or without the `tag:` prefix.
311        advertise_tags: Option<Vec<String>>,
312        /// An authentication key, so the node can log in without a browser.
313        /// Either the key itself or `file:<path>` to read it from a file. A key
314        /// given here is written to a private temporary file and passed by
315        /// reference: it never appears in the command line.
316        auth_key: Option<String>,
317        /// A control server other than Tailscale's own.
318        login_server: Option<String>,
319        /// A short name for the account profile this login creates.
320        nickname: Option<String>,
321        /// Seconds to wait for the login to complete before giving up. Capped
322        /// at 300; the client's own default is to wait for ever.
323        timeout_seconds: Option<u64>,
324    }
325}
326
327// ---------------------------------------------------------------------------
328// Argument helpers
329// ---------------------------------------------------------------------------
330
331/// Bound a caller's wait against the cap the connecting commands share.
332fn connect_timeouts(requested: Option<u64>) -> (u64, Duration) {
333    bounded_wait(requested, DEFAULT_CONNECT_TIMEOUT, MAX_CONNECT_TIMEOUT)
334}
335
336/// The `--name=<value>` form of a secret, over the shared
337/// [`secret_value`] that keeps the secret itself off the argument list.
338fn 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// ---------------------------------------------------------------------------
344// prefs get
345// ---------------------------------------------------------------------------
346
347#[derive(Debug, Deserialize, JsonSchema)]
348pub struct PrefsGetParams {
349    /// One preference by name, using the same names this server's parameters
350    /// use with underscores or the CLI's own with dashes. Omit it for all of
351    /// them.
352    #[serde(default)]
353    pub setting: Option<String>,
354    /// Answer with the `tailscale set` flags that would reproduce the current
355    /// preferences, instead of with the preference document. Useful for
356    /// recording a node's configuration before changing it.
357    #[serde(default)]
358    pub as_set_flags: bool,
359}
360
361#[derive(Debug, Serialize, JsonSchema)]
362pub struct PrefsReport {
363    /// The preference that was asked about, or null for all of them.
364    pub setting: Option<String>,
365    /// The preference document, when the flag rendering was not asked for.
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub preferences: Option<Value>,
368    /// The `tailscale set` flags that reproduce these preferences, when that
369    /// rendering was asked for.
370    #[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    // Flags first: Go stops parsing them at the first positional.
386    if let Some(setting) = &params.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// ---------------------------------------------------------------------------
411// prefs set
412// ---------------------------------------------------------------------------
413
414#[derive(Debug, Serialize, JsonSchema)]
415pub struct PrefsSetReport {
416    /// The flags that were applied, in the order they were passed. Exactly the
417    /// preferences that were named: everything else was left alone.
418    pub applied: Vec<String>,
419    /// Anything the client said while applying them.
420    #[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// ---------------------------------------------------------------------------
459// up
460// ---------------------------------------------------------------------------
461
462#[derive(Debug, Serialize, JsonSchema)]
463pub struct ConnectReport {
464    /// The `tailscale up --json` document when the client printed one; the
465    /// backend state and, for an interactive login, the URL to visit.
466    #[serde(skip_serializing_if = "Option::is_none")]
467    pub state: Option<Value>,
468    /// The URL a person has to open to finish an interactive login, when there
469    /// is one.
470    #[serde(skip_serializing_if = "Option::is_none")]
471    pub login_url: Option<String>,
472    /// How long the call was allowed to wait.
473    pub timeout_seconds: u64,
474    /// Anything the client said while connecting.
475    #[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    // The caller confirmed this call, which is the same question the client
496    // asks interactively.
497    args.push(ACCEPTED_RISKS.to_owned());
498
499    // Held until the call returns, so the file outlives the child that reads it.
500    let mut key = None;
501    if let Some(value) = &params.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// ---------------------------------------------------------------------------
525// down
526// ---------------------------------------------------------------------------
527
528#[derive(Debug, Deserialize, JsonSchema)]
529pub struct DownParams {
530    /// Why the node is being disconnected, for tailnets whose policy asks for
531    /// a reason.
532    #[serde(default)]
533    pub reason: Option<String>,
534}
535
536#[derive(Debug, Serialize, JsonSchema)]
537pub struct DisconnectReport {
538    /// What was done, so the answer reads the same whether or not the client
539    /// had anything to say.
540    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// ---------------------------------------------------------------------------
556// login
557// ---------------------------------------------------------------------------
558
559#[derive(Debug, Serialize, JsonSchema)]
560pub struct LoginReport {
561    /// The URL a person has to open to finish the login, when the client
562    /// printed one.
563    #[serde(skip_serializing_if = "Option::is_none")]
564    pub login_url: Option<String>,
565    /// How long the call was allowed to wait.
566    pub timeout_seconds: u64,
567    /// What the client printed, which is where the login instructions are.
568    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) = &params.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// ---------------------------------------------------------------------------
608// logout
609// ---------------------------------------------------------------------------
610
611#[derive(Debug, Deserialize, JsonSchema)]
612pub struct LogoutParams {
613    /// Why the node is being logged out, for tailnets whose policy asks for a
614    /// reason.
615    #[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// ---------------------------------------------------------------------------
630// switch
631// ---------------------------------------------------------------------------
632
633#[derive(Debug, Deserialize, JsonSchema)]
634pub struct SwitchParams {
635    /// Which account to switch to: the id from `tailscale_switch_list`, or the
636    /// tailnet, account or display name.
637    pub account: String,
638}
639
640#[derive(Debug, Serialize, JsonSchema)]
641pub struct SwitchReport {
642    /// What was asked for, echoed so the answer says which account it was.
643    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    /// Which stored account to forget: the id from `tailscale_switch_list`, or
666    /// the tailnet, account or display name.
667    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    /// A recorded sample of what the real client prints.
700    macro_rules! fixture {
701        ($name:literal) => {
702            include_str!(concat!("../../tests/fixtures/", $name))
703        };
704    }
705
706    /// An authentication key of the right shape and no value, so that a test
707    /// can look for it in an argument list without ever holding a real one.
708    const FAKE_KEY: &str = "tskey-auth-example-notarealkey";
709
710    /// Run a handler against a scripted client and report both what it answered
711    /// and what it ran.
712    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    /// The argument list of the single command a handler ran.
724    fn only(argv: &[Vec<String>]) -> &[String] {
725        assert_eq!(argv.len(), 1, "one command should have run: {argv:?}");
726        &argv[0]
727    }
728
729    // -- the preference set ---------------------------------------------------
730
731    #[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    // -- platform-specific preferences ---------------------------------------
787
788    #[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            // Where the preference exists, it is passed as the client spells it.
798            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    // -- secrets --------------------------------------------------------------
820
821    #[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    // -- bounded connects -----------------------------------------------------
873
874    #[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        // The client is given longer than the caller waits, so that it reports
881        // its own timeout rather than being killed part-way through.
882        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    // -- interactive logins ---------------------------------------------------
923
924    #[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        // `login` has no risk flag of its own, so none is invented for it.
963        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    // -- disconnecting --------------------------------------------------------
980
981    #[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    // -- profiles -------------------------------------------------------------
1013
1014    #[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    // -- reading preferences back --------------------------------------------
1041
1042    #[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        // Flags come before the positional, because Go stops parsing them at
1062        // the first one.
1063        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    // -- the toolset itself ---------------------------------------------------
1099
1100    #[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            // Reading is the only thing here that needs no confirmation, and
1119            // forgetting an account needs one without being self-severing.
1120            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}