Skip to main content

tailscale_mcp/tools/
local_serve.rs

1//! Publishing a local server: to the tailnet with `serve`, to the internet
2//! with `funnel`.
3//!
4//! Two things shape this module. The first is that both commands run in the
5//! *foreground* by default, holding the terminal until interrupted, which a
6//! tool call can never do — so `--bg=true` is passed on every call that sets
7//! or clears a handler, and no tool here can leave a process running. The
8//! second is `--yes=true`, which answers the client's interactive prompt
9//! before it is asked. A prompt nobody can see is a hang, and the question it
10//! asks is one this server asks in its own terms: funnel publishes to the
11//! public internet, so every funnel tool sits at the destructive tier and the
12//! one that exposes something requires `confirm: true` (DECISIONS Q22).
13//!
14//! Serve and funnel write to one configuration, which is why `reset` appears
15//! once rather than twice: `tailscale funnel reset` and `tailscale serve
16//! reset` clear the same thing.
17//!
18//! Reading the configuration and writing it back is meant to be a no-op, so
19//! `tailscale_serve_get_config` answers with the document the client printed
20//! and `tailscale_serve_set_config` takes that document back inline. The CLI
21//! insists on reading it from a file, so the document is written to a private
22//! temporary file that exists only for the length of the call.
23
24use rmcp::schemars::JsonSchema;
25use serde::{Deserialize, Serialize};
26use serde_json::Value;
27use tailscale_cli::{Invocation, PrivateFile};
28
29use crate::cli;
30use crate::context::ToolContext;
31use crate::error::{ErrorCode, ToolError, ToolResult};
32use crate::tools::common::{find_url, flag, note, printed, push_list, push_text, report};
33
34crate::tools! {
35    /// Serve a local server, a file, a directory or a block of text to the
36    /// tailnet. Reachable by other nodes on the tailnet only; use
37    /// `tailscale_funnel_set` to publish to the internet. Runs in the
38    /// background: the call returns as soon as the handler is in place.
39    tailscale_serve_set => ServeSetParams, serve_set,
40        toolset: LocalServe, tier: Write;
41
42    /// Stop serving one endpoint, leaving every other handler in place. Names
43    /// the same endpoint the handler was added on; reports `not_found` when
44    /// there is no handler there.
45    tailscale_serve_off => ServeOffParams, serve_off,
46        toolset: LocalServe, tier: Write;
47
48    /// Remove every serve and funnel handler on this node at once. There is
49    /// one configuration behind both commands, so this clears both. Use
50    /// `tailscale_serve_off` to remove a single endpoint.
51    tailscale_serve_reset => NoParams, serve_reset,
52        toolset: LocalServe, tier: Destructive, confirm: true;
53
54    /// Stop this node accepting new connections for a service, while letting
55    /// the connections it already has finish. Bring it back with
56    /// `tailscale_serve_advertise`.
57    tailscale_serve_drain => ServiceParams, serve_drain,
58        toolset: LocalServe, tier: Write, since: "1.90";
59
60    /// Remove every handler configured for one service on this node. Unlike
61    /// `tailscale_serve_drain` this discards the configuration rather than
62    /// stopping at the door, and `tailscale_serve_advertise` will not bring it
63    /// back.
64    tailscale_serve_clear => ServiceParams, serve_clear,
65        toolset: LocalServe, tier: Destructive, confirm: true, since: "1.90";
66
67    /// Offer this node to the tailnet as a host for a service, which is what
68    /// undoes `tailscale_serve_drain`. Not needed after
69    /// `tailscale_serve_set`, which advertises the service itself.
70    tailscale_serve_advertise => ServiceParams, serve_advertise,
71        toolset: LocalServe, tier: Write, since: "1.90";
72
73    /// Read the service configuration this node is hosting, as a document that
74    /// `tailscale_serve_set_config` takes back unchanged. Reads only.
75    tailscale_serve_get_config => GetConfigParams, serve_get_config,
76        toolset: LocalServe, tier: Read, idempotent: true, since: "1.90";
77
78    /// Replace the service configuration on this node with the document given
79    /// here, which overwrites every handler in scope. Writing back a document
80    /// that `tailscale_serve_get_config` produced changes nothing.
81    tailscale_serve_set_config => SetConfigParams, serve_set_config,
82        toolset: LocalServe, tier: Write, idempotent: true, since: "1.90";
83
84    /// Publish a local server to the **public internet** through Tailscale
85    /// Funnel. Anyone on the internet who knows the name can reach it, with no
86    /// tailnet membership and no authentication of any kind. Use
87    /// `tailscale_serve_set` for a server that only the tailnet should reach.
88    /// Funnel has to be enabled for the tailnet in the admin console first; on
89    /// a tailnet where it is not, the client waits rather than failing, and
90    /// this call comes back as a timeout carrying the URL that enables it.
91    tailscale_funnel_set => FunnelSetParams, funnel_set,
92        toolset: LocalServe, tier: Destructive, confirm: true;
93
94    /// Stop publishing one endpoint to the internet. The handler stops being
95    /// reachable from outside the tailnet; use `tailscale_serve_off` to remove
96    /// it from the tailnet as well.
97    tailscale_funnel_off => FunnelOffParams, funnel_off,
98        toolset: LocalServe, tier: Destructive;
99}
100
101// ---------------------------------------------------------------------------
102// The shape of every call
103// ---------------------------------------------------------------------------
104
105/// Run in the background. The client's default is to hold the foreground until
106/// it is interrupted, which no tool call can do.
107const BACKGROUND: &str = "--bg=true";
108
109/// Answer the client's own prompt. The question it asks — whether to expose
110/// this to the internet, whether to take on a service — is the question the
111/// `confirm` parameter asks in terms the caller can see, so leaving it for a
112/// terminal nobody is watching would only hang the call (DECISIONS Q22).
113const NO_PROMPT: &str = "--yes=true";
114
115/// The word that turns a set into a clear, in the position the target occupies.
116const OFF: &str = "off";
117
118/// A tool that takes nothing.
119#[derive(Debug, Deserialize, JsonSchema)]
120pub struct NoParams {}
121
122/// Declare a parameter struct carrying the endpoint selectors that `serve` and
123/// `funnel` share, plus whatever else that command takes.
124///
125/// A macro rather than one struct behind `#[serde(flatten)]`, for the reason
126/// recorded in DECISIONS Q21: a flattened schema is a composition with no
127/// top-level properties, and the confirmation gate injects into those.
128macro_rules! endpoint_params {
129    (
130        $(#[doc = $doc:literal])*
131        $name:ident { $( $(#[doc = $field_doc:literal])* $field:ident : $ty:ty ),* $(,)? }
132    ) => {
133        $(#[doc = $doc])*
134        #[derive(Debug, Default, Deserialize, JsonSchema)]
135        pub struct $name {
136            /// Expose an HTTPS server on this port. The default when no
137            /// endpoint is named at all is HTTPS on port 443.
138            #[serde(default)]
139            pub https: Option<u16>,
140            /// Forward raw TCP on this port instead of speaking HTTP.
141            #[serde(default)]
142            pub tcp: Option<u16>,
143            /// Forward TCP on this port, terminating TLS here rather than
144            /// passing it through.
145            #[serde(default)]
146            pub tls_terminated_tcp: Option<u16>,
147            /// Serve under this path rather than at the root, so that several
148            /// servers can share one port.
149            #[serde(default)]
150            pub set_path: Option<String>,
151            $(
152                $(#[doc = $field_doc])*
153                #[serde(default)]
154                pub $field: $ty,
155            )*
156        }
157
158        impl $name {
159            /// The endpoints this call named, for [`one_endpoint`] to reduce
160            /// to the one the client will accept.
161            fn shared_endpoints(&self) -> Vec<(&'static str, u16)> {
162                let mut chosen = Vec::new();
163                if let Some(port) = self.https {
164                    chosen.push(("https", port));
165                }
166                if let Some(port) = self.tcp {
167                    chosen.push(("tcp", port));
168                }
169                if let Some(port) = self.tls_terminated_tcp {
170                    chosen.push(("tls-terminated-tcp", port));
171                }
172                chosen
173            }
174        }
175    };
176}
177
178endpoint_params! {
179    /// What to serve to the tailnet, and where.
180    ServeSetParams {
181        /// What to serve: a port number such as `3000`, a URL such as
182        /// `http://localhost:3000/api`, a path to a file or directory, a
183        /// `text:` literal, or a `unix:` socket path. Required.
184        target: String,
185        /// Expose an HTTP server on this port. Plain HTTP inside the tailnet,
186        /// with no certificate.
187        http: Option<u16>,
188        /// Serve for a Tailscale service under its own address, rather than
189        /// for this node. A `svc:` name, or a bare name this server prefixes.
190        service: Option<String>,
191        /// Forward all traffic for the service to this machine, rather than
192        /// proxying named ports. Services only.
193        tun: Option<bool>,
194        /// Application capabilities to pass through to the server behind this
195        /// handler.
196        accept_app_caps: Option<Vec<String>>,
197        /// Speak the PROXY protocol to the server behind a TCP forwarder, so
198        /// that it sees the client's address. Version `1` or `2`.
199        proxy_protocol: Option<u8>,
200    }
201}
202
203endpoint_params! {
204    /// Which endpoint to stop serving.
205    ServeOffParams {
206        /// Stop serving plain HTTP on this port.
207        http: Option<u16>,
208        /// Stop serving for this Tailscale service rather than for this node.
209        service: Option<String>,
210    }
211}
212
213endpoint_params! {
214    /// What to publish to the internet, and where.
215    FunnelSetParams {
216        /// What to publish: a port number such as `3000`, a URL such as
217        /// `http://localhost:3000/api`, a path to a file or directory, a
218        /// `text:` literal, or a `unix:` socket path. Required.
219        target: String,
220        /// Speak the PROXY protocol to the server behind a TCP forwarder, so
221        /// that it sees the client's address. Version `1` or `2`.
222        proxy_protocol: Option<u8>,
223    }
224}
225
226endpoint_params! {
227    /// Which endpoint to stop publishing.
228    FunnelOffParams {}
229}
230
231/// One Tailscale service by name.
232#[derive(Debug, Deserialize, JsonSchema)]
233pub struct ServiceParams {
234    /// The service, as a `svc:` name such as `svc:web`. A bare name is given
235    /// the prefix.
236    pub service: String,
237}
238
239// ---------------------------------------------------------------------------
240// Argument helpers
241// ---------------------------------------------------------------------------
242
243/// Reduce the endpoints a call named to the single one the client accepts.
244///
245/// `None` means the caller named none and the client's own default — HTTPS on
246/// 443 — applies. Two is refused here rather than by a client that would take
247/// the last one silently.
248fn one_endpoint(chosen: Vec<(&'static str, u16)>) -> ToolResult<Option<(&'static str, u16)>> {
249    match chosen.len() {
250        0 => Ok(None),
251        1 => Ok(chosen.into_iter().next()),
252        _ => Err(ToolError::invalid_args(format!(
253            "name one endpoint, not {}: {}",
254            chosen.len(),
255            chosen
256                .iter()
257                .map(|(scheme, port)| format!("{scheme}:{port}"))
258                .collect::<Vec<_>>()
259                .join(", ")
260        ))),
261    }
262}
263
264/// Add the endpoint flag, when there is one to add.
265fn push_endpoint(args: &mut Vec<String>, endpoint: Option<(&str, u16)>) {
266    if let Some((scheme, port)) = endpoint {
267        args.push(format!("--{scheme}={port}"));
268    }
269}
270
271/// How an endpoint is written in an answer.
272fn describe(endpoint: Option<(&str, u16)>) -> Option<String> {
273    endpoint.map(|(scheme, port)| format!("{scheme}:{port}"))
274}
275
276/// A service name in the form the client insists on.
277///
278/// The client rejects a bare name with a flag-parsing error rather than adding
279/// the prefix itself, which is not an answer a caller can act on.
280fn service_name(service: &str) -> String {
281    let service = service.trim();
282    if service.starts_with("svc:") {
283        service.to_owned()
284    } else {
285        format!("svc:{service}")
286    }
287}
288
289/// The PROXY protocol version, which the client takes as `1` or `2`.
290fn proxy_protocol(version: Option<u8>) -> ToolResult<Option<String>> {
291    match version {
292        None => Ok(None),
293        Some(v @ (1 | 2)) => Ok(Some(format!("--proxy-protocol={v}"))),
294        Some(other) => Err(ToolError::invalid_args(format!(
295            "the PROXY protocol has versions 1 and 2, not {other}"
296        ))),
297    }
298}
299
300/// Which services a configuration exchange covers.
301///
302/// Named rather than defaulted: `--all` on a write replaces the configuration
303/// of every service this node hosts, which is not something to arrive at by
304/// leaving a parameter out.
305fn scope(service: Option<&str>, all: bool) -> ToolResult<(String, String)> {
306    match (service, all) {
307        (Some(_), true) => Err(ToolError::invalid_args(
308            "name one service or set `all`, not both",
309        )),
310        (Some(service), false) => {
311            let named = service_name(service);
312            Ok((format!("--service={named}"), named))
313        }
314        (None, true) => Ok((flag("all", true), "all".to_owned())),
315        (None, false) => Err(ToolError::invalid_args(
316            "name the service this covers, or set `all` to true for every \
317             service this node hosts",
318        )),
319    }
320}
321
322// ---------------------------------------------------------------------------
323// Reports
324// ---------------------------------------------------------------------------
325
326/// The answer to a call that added or removed a handler.
327#[derive(Debug, Serialize, JsonSchema)]
328pub struct HandlerReport {
329    /// What is now being served, or `"off"` for a handler that was removed.
330    pub target: String,
331    /// The endpoint as `scheme:port`, when the call named one. Absent when the
332    /// client's own default applied.
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub endpoint: Option<String>,
335    /// The path the handler was attached under, when it was not the root.
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub path: Option<String>,
338    /// The service this covers, when it was for a service rather than for this
339    /// node.
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub service: Option<String>,
342    /// Whether this is reachable from the public internet. `false` means the
343    /// tailnet and nothing outside it.
344    pub public: bool,
345    /// The address the handler is reachable at, when the client printed one.
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub url: Option<String>,
348    /// Everything the client printed, which for `serve` is a short report of
349    /// what it set up.
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub printed: Option<String>,
352}
353
354/// The answer to a call that acted on a whole service or on the whole node.
355#[derive(Debug, Serialize, JsonSchema)]
356pub struct ScopeReport {
357    /// What the call covered: a `svc:` name, or `"this node"`.
358    pub scope: String,
359    /// What was done to it.
360    pub outcome: String,
361    /// Anything the client said.
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub note: Option<String>,
364}
365
366/// A service configuration document, on its way out or on its way in.
367#[derive(Debug, Serialize, JsonSchema)]
368pub struct ConfigReport {
369    /// What the document covers: a `svc:` name, or `"all"`.
370    pub scope: String,
371    /// The configuration itself. Pass it back to
372    /// `tailscale_serve_set_config` unchanged and nothing changes.
373    pub configuration: Value,
374    /// Anything the client said.
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub note: Option<String>,
377}
378
379// ---------------------------------------------------------------------------
380// serve
381// ---------------------------------------------------------------------------
382
383async fn serve_set(ctx: &ToolContext, params: ServeSetParams) -> ToolResult<Value> {
384    let meta = &metas::tailscale_serve_set;
385    let mut chosen = params.shared_endpoints();
386    if let Some(port) = params.http {
387        chosen.push(("http", port));
388    }
389    let endpoint = one_endpoint(chosen)?;
390    if params.tun == Some(true) && params.service.is_none() {
391        return Err(ToolError::invalid_args(
392            "`tun` forwards all traffic for a service, so it needs a `service`",
393        ));
394    }
395    let service = params.service.as_deref().map(service_name);
396
397    let mut args = vec![BACKGROUND.to_owned(), NO_PROMPT.to_owned()];
398    push_endpoint(&mut args, endpoint);
399    push_text(&mut args, "set-path", params.set_path.as_deref());
400    push_text(&mut args, "service", service.as_deref());
401    args.extend(proxy_protocol(params.proxy_protocol)?);
402    push_list(
403        &mut args,
404        "accept-app-caps",
405        params.accept_app_caps.as_deref(),
406    );
407    if let Some(tun) = params.tun {
408        args.push(flag("tun", tun));
409    }
410    // Last, because Go stops reading flags at the first positional.
411    args.push(params.target.clone());
412
413    let output = cli::run(ctx, meta, Invocation::mutate(prefixed("serve", args))).await?;
414    let stdout = output.stdout_str().into_owned();
415    report(HandlerReport {
416        target: params.target,
417        endpoint: describe(endpoint),
418        path: params.set_path,
419        service,
420        public: false,
421        url: find_url(&stdout),
422        printed: printed(ctx, &output),
423    })
424}
425
426async fn serve_off(ctx: &ToolContext, params: ServeOffParams) -> ToolResult<Value> {
427    let meta = &metas::tailscale_serve_off;
428    let mut chosen = params.shared_endpoints();
429    if let Some(port) = params.http {
430        chosen.push(("http", port));
431    }
432    let endpoint = one_endpoint(chosen)?;
433    let service = params.service.as_deref().map(service_name);
434
435    let mut args = vec![BACKGROUND.to_owned(), NO_PROMPT.to_owned()];
436    push_endpoint(&mut args, endpoint);
437    push_text(&mut args, "set-path", params.set_path.as_deref());
438    push_text(&mut args, "service", service.as_deref());
439    args.push(OFF.to_owned());
440
441    let output = cli::run(ctx, meta, Invocation::mutate(prefixed("serve", args))).await?;
442    report(HandlerReport {
443        target: OFF.to_owned(),
444        endpoint: describe(endpoint),
445        path: params.set_path,
446        service,
447        public: false,
448        url: None,
449        printed: printed(ctx, &output),
450    })
451}
452
453async fn serve_reset(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
454    let meta = &metas::tailscale_serve_reset;
455    let output = cli::run(ctx, meta, Invocation::mutate(["serve", "reset"])).await?;
456    report(ScopeReport {
457        scope: "this node".to_owned(),
458        outcome: "every serve and funnel handler removed".to_owned(),
459        note: note(ctx, &output.stderr),
460    })
461}
462
463// ---------------------------------------------------------------------------
464// services
465// ---------------------------------------------------------------------------
466
467/// The three service lifecycle commands, which differ only in the word.
468async fn service_command(
469    ctx: &ToolContext,
470    meta: &crate::meta::ToolMeta,
471    subcommand: &str,
472    outcome: &str,
473    params: ServiceParams,
474) -> ToolResult<Value> {
475    let service = service_name(&params.service);
476    let output = cli::run(
477        ctx,
478        meta,
479        Invocation::mutate(["serve", subcommand, &service]),
480    )
481    .await?;
482    report(ScopeReport {
483        scope: service,
484        outcome: outcome.to_owned(),
485        note: note(ctx, &output.stderr),
486    })
487}
488
489async fn serve_drain(ctx: &ToolContext, params: ServiceParams) -> ToolResult<Value> {
490    service_command(
491        ctx,
492        &metas::tailscale_serve_drain,
493        "drain",
494        "no longer accepting new connections; existing ones continue",
495        params,
496    )
497    .await
498}
499
500async fn serve_clear(ctx: &ToolContext, params: ServiceParams) -> ToolResult<Value> {
501    service_command(
502        ctx,
503        &metas::tailscale_serve_clear,
504        "clear",
505        "every handler for the service removed from this node",
506        params,
507    )
508    .await
509}
510
511async fn serve_advertise(ctx: &ToolContext, params: ServiceParams) -> ToolResult<Value> {
512    service_command(
513        ctx,
514        &metas::tailscale_serve_advertise,
515        "advertise",
516        "advertised to the tailnet as a host for the service",
517        params,
518    )
519    .await
520}
521
522// ---------------------------------------------------------------------------
523// the configuration exchange
524// ---------------------------------------------------------------------------
525
526#[derive(Debug, Deserialize, JsonSchema)]
527pub struct GetConfigParams {
528    /// One service by name. Give this or `all`, not both.
529    #[serde(default)]
530    pub service: Option<String>,
531    /// Every service this node hosts.
532    #[serde(default)]
533    pub all: bool,
534}
535
536#[derive(Debug, Deserialize, JsonSchema)]
537pub struct SetConfigParams {
538    /// The configuration document, in the shape
539    /// `tailscale_serve_get_config` answers with.
540    pub configuration: Value,
541    /// The one service this replaces the configuration of. Give this or `all`,
542    /// not both.
543    #[serde(default)]
544    pub service: Option<String>,
545    /// Replace the configuration of every service this node hosts.
546    #[serde(default)]
547    pub all: bool,
548}
549
550async fn serve_get_config(ctx: &ToolContext, params: GetConfigParams) -> ToolResult<Value> {
551    let meta = &metas::tailscale_serve_get_config;
552    let (selector, scope) = scope(params.service.as_deref(), params.all)?;
553    // The client's usage names a file, but it prints the document to standard
554    // output and ignores the argument, so there is no file in this direction.
555    let text = cli::run_text(
556        ctx,
557        meta,
558        Invocation::read(["serve", "get-config", &selector]),
559    )
560    .await?;
561    let configuration = parse_config(&text)?;
562    report(ConfigReport {
563        scope,
564        configuration,
565        note: None,
566    })
567}
568
569async fn serve_set_config(ctx: &ToolContext, params: SetConfigParams) -> ToolResult<Value> {
570    let meta = &metas::tailscale_serve_set_config;
571    let (selector, scope) = scope(params.service.as_deref(), params.all)?;
572    // Held for the length of the call: dropping it removes the file and the
573    // private directory it lives in.
574    let file = config_file(&params.configuration)?;
575    let output = cli::run(
576        ctx,
577        meta,
578        Invocation::mutate(["serve", "set-config", &selector, &file.arg()]),
579    )
580    .await?;
581    report(ConfigReport {
582        scope,
583        configuration: params.configuration,
584        note: note(ctx, &output.stderr),
585    })
586}
587
588/// The document the client printed.
589fn parse_config(text: &str) -> ToolResult<Value> {
590    serde_json::from_str(text.trim()).map_err(|e| {
591        ToolError::new(
592            ErrorCode::CliFailed,
593            format!("`tailscale serve get-config` did not print JSON: {e}"),
594        )
595    })
596}
597
598/// The document on its way to a client that will only read it from a file.
599///
600/// Written to a directory only this user may enter, and removed with the
601/// returned value, so a configuration that names an internal host does not
602/// outlive the call in a world-readable temporary directory.
603fn config_file(configuration: &Value) -> ToolResult<PrivateFile> {
604    let body = serde_json::to_vec_pretty(configuration).map_err(|e| {
605        ToolError::invalid_args(format!("the configuration is not a JSON document: {e}"))
606    })?;
607    PrivateFile::written("serve-config.json", &body).map_err(|e| {
608        ToolError::new(
609            ErrorCode::CliFailed,
610            format!("the configuration could not be written to a private file: {e}"),
611        )
612    })
613}
614
615// ---------------------------------------------------------------------------
616// funnel
617// ---------------------------------------------------------------------------
618
619async fn funnel_set(ctx: &ToolContext, params: FunnelSetParams) -> ToolResult<Value> {
620    let meta = &metas::tailscale_funnel_set;
621    let endpoint = one_endpoint(params.shared_endpoints())?;
622
623    let mut args = vec![BACKGROUND.to_owned(), NO_PROMPT.to_owned()];
624    push_endpoint(&mut args, endpoint);
625    push_text(&mut args, "set-path", params.set_path.as_deref());
626    args.extend(proxy_protocol(params.proxy_protocol)?);
627    args.push(params.target.clone());
628
629    let output = cli::run(ctx, meta, Invocation::mutate(prefixed("funnel", args))).await?;
630    let stdout = output.stdout_str().into_owned();
631    report(HandlerReport {
632        target: params.target,
633        endpoint: describe(endpoint),
634        path: params.set_path,
635        service: None,
636        public: true,
637        url: find_url(&stdout),
638        printed: printed(ctx, &output),
639    })
640}
641
642async fn funnel_off(ctx: &ToolContext, params: FunnelOffParams) -> ToolResult<Value> {
643    let meta = &metas::tailscale_funnel_off;
644    let endpoint = one_endpoint(params.shared_endpoints())?;
645
646    let mut args = vec![BACKGROUND.to_owned(), NO_PROMPT.to_owned()];
647    push_endpoint(&mut args, endpoint);
648    push_text(&mut args, "set-path", params.set_path.as_deref());
649    args.push(OFF.to_owned());
650
651    let output = cli::run(ctx, meta, Invocation::mutate(prefixed("funnel", args))).await?;
652    report(HandlerReport {
653        target: OFF.to_owned(),
654        endpoint: describe(endpoint),
655        path: params.set_path,
656        service: None,
657        public: false,
658        url: None,
659        printed: printed(ctx, &output),
660    })
661}
662
663/// The command name in front of the arguments built for it.
664fn prefixed(command: &str, args: Vec<String>) -> Vec<String> {
665    let mut all = Vec::with_capacity(args.len() + 1);
666    all.push(command.to_owned());
667    all.extend(args);
668    all
669}
670
671#[cfg(test)]
672mod tests {
673    use std::path::Path;
674    use std::sync::Arc;
675
676    use serde_json::json;
677
678    use super::*;
679
680    use crate::meta::Tier;
681    use crate::testing::{Reply, StubBackend, context};
682
683    /// What the client prints when a handler goes up, with this tailnet's own
684    /// names replaced by the documentation ones.
685    const SERVE_OUTPUT: &str = "Available within your tailnet:\n\n\
686        https://workstation.example-tailnet.ts.net/\n\
687        |-- proxy http://127.0.0.1:3000\n\n\
688        Serve started and running in the background.\n";
689
690    /// Run a handler against a scripted client and report both what it answered
691    /// and what it ran.
692    async fn against<F, P, Fut>(reply: Reply, handler: F, params: P) -> (Value, Vec<Vec<String>>)
693    where
694        F: FnOnce(ToolContext, P) -> Fut,
695        Fut: Future<Output = ToolResult<Value>>,
696    {
697        let backend = Arc::new(StubBackend::always(reply));
698        let ctx = context(Arc::clone(&backend));
699        let value = handler(ctx, params).await.expect("the handler succeeds");
700        (value, backend.argv())
701    }
702
703    /// The same for a call that should be refused, which is only interesting
704    /// alongside the fact that nothing was run.
705    async fn refused<F, P, Fut>(handler: F, params: P) -> ToolError
706    where
707        F: FnOnce(ToolContext, P) -> Fut,
708        Fut: Future<Output = ToolResult<Value>>,
709    {
710        let backend = Arc::new(StubBackend::always(Reply::ok("")));
711        let ctx = context(Arc::clone(&backend));
712        let error = handler(ctx, params).await.expect_err("the handler refuses");
713        assert!(
714            backend.argv().is_empty(),
715            "nothing should have run: {:?}",
716            backend.argv()
717        );
718        error
719    }
720
721    /// The argument list of the single command a handler ran.
722    fn only(argv: &[Vec<String>]) -> &[String] {
723        assert_eq!(argv.len(), 1, "one command should have run: {argv:?}");
724        &argv[0]
725    }
726
727    // -- serving to the tailnet ----------------------------------------------
728
729    #[tokio::test]
730    async fn everything_that_can_hold_the_terminal_lets_it_go() {
731        // The one property that makes these safe to call at all: no form of
732        // any of them runs in the foreground, and none can stop on a prompt.
733        let calls: Vec<Vec<String>> = vec![
734            against(
735                Reply::ok(""),
736                |ctx, p| async move { serve_set(&ctx, p).await },
737                ServeSetParams {
738                    target: "3000".to_owned(),
739                    ..ServeSetParams::default()
740                },
741            )
742            .await
743            .1,
744            against(
745                Reply::ok(""),
746                |ctx, p| async move { serve_off(&ctx, p).await },
747                ServeOffParams::default(),
748            )
749            .await
750            .1,
751            against(
752                Reply::ok(""),
753                |ctx, p| async move { funnel_set(&ctx, p).await },
754                FunnelSetParams {
755                    target: "3000".to_owned(),
756                    ..FunnelSetParams::default()
757                },
758            )
759            .await
760            .1,
761            against(
762                Reply::ok(""),
763                |ctx, p| async move { funnel_off(&ctx, p).await },
764                FunnelOffParams::default(),
765            )
766            .await
767            .1,
768        ]
769        .into_iter()
770        .map(|argv| only(&argv).to_vec())
771        .collect();
772
773        for args in calls {
774            assert!(
775                args.contains(&BACKGROUND.to_owned()) && args.contains(&NO_PROMPT.to_owned()),
776                "{args:?} could hold the foreground or stop on a prompt"
777            );
778        }
779    }
780
781    #[tokio::test]
782    async fn the_target_comes_last_so_the_flags_are_read_as_flags() {
783        let (_, argv) = against(
784            Reply::ok(""),
785            |ctx, p| async move { serve_set(&ctx, p).await },
786            ServeSetParams {
787                target: "http://localhost:3000/api".to_owned(),
788                https: Some(8443),
789                set_path: Some("/api".to_owned()),
790                ..ServeSetParams::default()
791            },
792        )
793        .await;
794        assert_eq!(
795            only(&argv).last().map(String::as_str),
796            Some("http://localhost:3000/api")
797        );
798        assert_eq!(
799            only(&argv),
800            [
801                "serve",
802                "--bg=true",
803                "--yes=true",
804                "--https=8443",
805                "--set-path=/api",
806                "http://localhost:3000/api",
807            ]
808        );
809    }
810
811    #[tokio::test]
812    async fn each_endpoint_is_passed_as_the_flag_the_client_knows() {
813        for (params, expected) in [
814            (
815                ServeSetParams {
816                    http: Some(80),
817                    ..ServeSetParams::default()
818                },
819                "--http=80",
820            ),
821            (
822                ServeSetParams {
823                    https: Some(443),
824                    ..ServeSetParams::default()
825                },
826                "--https=443",
827            ),
828            (
829                ServeSetParams {
830                    tcp: Some(2222),
831                    ..ServeSetParams::default()
832                },
833                "--tcp=2222",
834            ),
835            (
836                ServeSetParams {
837                    tls_terminated_tcp: Some(2223),
838                    ..ServeSetParams::default()
839                },
840                "--tls-terminated-tcp=2223",
841            ),
842        ] {
843            let (answer, argv) = against(
844                Reply::ok(""),
845                |ctx, p| async move { serve_set(&ctx, p).await },
846                ServeSetParams {
847                    target: "3000".to_owned(),
848                    ..params
849                },
850            )
851            .await;
852            assert!(
853                only(&argv).contains(&expected.to_owned()),
854                "{expected} missing from {:?}",
855                only(&argv)
856            );
857            let (scheme, port) = expected.trim_start_matches("--").split_once('=').unwrap();
858            assert_eq!(answer["endpoint"], json!(format!("{scheme}:{port}")));
859        }
860    }
861
862    #[tokio::test]
863    async fn naming_no_endpoint_leaves_the_client_its_own_default() {
864        let (answer, argv) = against(
865            Reply::ok(""),
866            |ctx, p| async move { serve_set(&ctx, p).await },
867            ServeSetParams {
868                target: "3000".to_owned(),
869                ..ServeSetParams::default()
870            },
871        )
872        .await;
873        assert!(
874            !only(&argv).iter().any(|arg| arg.starts_with("--http")
875                || arg.starts_with("--tcp")
876                || arg.starts_with("--tls")),
877            "no endpoint should have been chosen for the client: {:?}",
878            only(&argv)
879        );
880        assert_eq!(answer.get("endpoint"), None);
881    }
882
883    #[tokio::test]
884    async fn forwarding_all_traffic_needs_a_service_to_forward_it_for() {
885        let error = refused(
886            |ctx, p| async move { serve_set(&ctx, p).await },
887            ServeSetParams {
888                target: "3000".to_owned(),
889                tun: Some(true),
890                ..ServeSetParams::default()
891            },
892        )
893        .await;
894        assert_eq!(error.code, ErrorCode::InvalidArgs);
895    }
896
897    #[tokio::test]
898    async fn a_proxy_protocol_version_the_client_does_not_have_is_refused() {
899        let error = refused(
900            |ctx, p| async move { serve_set(&ctx, p).await },
901            ServeSetParams {
902                target: "3000".to_owned(),
903                proxy_protocol: Some(3),
904                ..ServeSetParams::default()
905            },
906        )
907        .await;
908        assert_eq!(error.code, ErrorCode::InvalidArgs);
909        assert_eq!(
910            proxy_protocol(Some(2)).unwrap(),
911            Some("--proxy-protocol=2".to_owned())
912        );
913    }
914
915    #[tokio::test]
916    async fn a_service_call_carries_everything_a_service_needs() {
917        let (answer, argv) = against(
918            Reply::ok(""),
919            |ctx, p| async move { serve_set(&ctx, p).await },
920            ServeSetParams {
921                target: "3000".to_owned(),
922                https: Some(443),
923                service: Some("web".to_owned()),
924                tun: Some(true),
925                accept_app_caps: Some(vec!["example.com/cap/a".to_owned()]),
926                ..ServeSetParams::default()
927            },
928        )
929        .await;
930        assert_eq!(
931            only(&argv),
932            [
933                "serve",
934                "--bg=true",
935                "--yes=true",
936                "--https=443",
937                "--service=svc:web",
938                "--accept-app-caps=example.com/cap/a",
939                "--tun=true",
940                "3000",
941            ]
942        );
943        assert_eq!(answer["service"], json!("svc:web"));
944    }
945
946    #[test]
947    fn a_bare_service_name_is_given_the_prefix_the_client_insists_on() {
948        // The client rejects `web` with a flag-parsing error rather than
949        // adding the prefix itself.
950        assert_eq!(service_name("web"), "svc:web");
951        assert_eq!(service_name("  web  "), "svc:web");
952        assert_eq!(service_name("svc:web"), "svc:web");
953    }
954
955    #[tokio::test]
956    async fn the_address_the_handler_answers_at_comes_back_with_it() {
957        let (answer, _) = against(
958            Reply::ok(SERVE_OUTPUT),
959            |ctx, p| async move { serve_set(&ctx, p).await },
960            ServeSetParams {
961                target: "3000".to_owned(),
962                ..ServeSetParams::default()
963            },
964        )
965        .await;
966        assert_eq!(
967            answer["url"],
968            json!("https://workstation.example-tailnet.ts.net/")
969        );
970        assert!(
971            answer["printed"]
972                .as_str()
973                .is_some_and(|p| p.contains("Serve started")),
974            "the client's own report should come back: {answer}"
975        );
976    }
977
978    // -- taking it down again -------------------------------------------------
979
980    #[tokio::test]
981    async fn turning_one_endpoint_off_leaves_every_other_handler_alone() {
982        let (answer, argv) = against(
983            Reply::ok(""),
984            |ctx, p| async move { serve_off(&ctx, p).await },
985            ServeOffParams {
986                https: Some(8443),
987                set_path: Some("/api".to_owned()),
988                ..ServeOffParams::default()
989            },
990        )
991        .await;
992        assert_eq!(
993            only(&argv),
994            [
995                "serve",
996                "--bg=true",
997                "--yes=true",
998                "--https=8443",
999                "--set-path=/api",
1000                "off",
1001            ]
1002        );
1003        assert_eq!(answer["target"], json!("off"));
1004        assert_eq!(answer["endpoint"], json!("https:8443"));
1005    }
1006
1007    // -- the service lifecycle ------------------------------------------------
1008
1009    #[tokio::test]
1010    async fn each_service_command_passes_the_service_and_nothing_else() {
1011        /// The three differ only in the word, so they are checked the same way.
1012        macro_rules! runs {
1013            ($handler:ident, $subcommand:literal) => {{
1014                let (answer, argv) = against(
1015                    Reply::ok(""),
1016                    |ctx, p| async move { $handler(&ctx, p).await },
1017                    ServiceParams {
1018                        service: "web".to_owned(),
1019                    },
1020                )
1021                .await;
1022                assert_eq!(only(&argv), ["serve", $subcommand, "svc:web"]);
1023                assert_eq!(answer["scope"], json!("svc:web"));
1024                assert!(answer["outcome"].as_str().is_some_and(|o| !o.is_empty()));
1025            }};
1026        }
1027        runs!(serve_drain, "drain");
1028        runs!(serve_clear, "clear");
1029        runs!(serve_advertise, "advertise");
1030    }
1031
1032    // -- the configuration exchange -------------------------------------------
1033
1034    #[tokio::test]
1035    async fn reading_the_configuration_asks_for_a_scope_and_gets_the_document() {
1036        let (answer, argv) = against(
1037            Reply::ok("{\"version\": \"0.0.1\"}\n"),
1038            |ctx, p| async move { serve_get_config(&ctx, p).await },
1039            GetConfigParams {
1040                service: None,
1041                all: true,
1042            },
1043        )
1044        .await;
1045        assert_eq!(only(&argv), ["serve", "get-config", "--all=true"]);
1046        assert_eq!(answer["scope"], json!("all"));
1047        assert_eq!(answer["configuration"], json!({"version": "0.0.1"}));
1048    }
1049
1050    #[tokio::test]
1051    async fn one_service_is_read_by_the_name_the_client_uses() {
1052        let (answer, argv) = against(
1053            Reply::ok("{}"),
1054            |ctx, p| async move { serve_get_config(&ctx, p).await },
1055            GetConfigParams {
1056                service: Some("web".to_owned()),
1057                all: false,
1058            },
1059        )
1060        .await;
1061        assert_eq!(only(&argv), ["serve", "get-config", "--service=svc:web"]);
1062        assert_eq!(answer["scope"], json!("svc:web"));
1063    }
1064
1065    #[tokio::test]
1066    async fn writing_the_configuration_hands_the_client_a_file_that_does_not_outlive_the_call() {
1067        let document = json!({"version": "0.0.1", "services": {}});
1068        let (answer, argv) = against(
1069            Reply::ok(""),
1070            |ctx, p| async move { serve_set_config(&ctx, p).await },
1071            SetConfigParams {
1072                configuration: document.clone(),
1073                service: None,
1074                all: true,
1075            },
1076        )
1077        .await;
1078        let args = only(&argv);
1079        assert_eq!(&args[..3], ["serve", "set-config", "--all=true"]);
1080        let path = Path::new(&args[3]);
1081        assert!(
1082            !path.exists(),
1083            "the configuration file should be gone once the call is over: {}",
1084            path.display()
1085        );
1086        assert_eq!(answer["configuration"], document);
1087    }
1088
1089    #[test]
1090    fn the_document_that_comes_out_is_the_document_that_goes_back_in() {
1091        // What "reading the configuration and writing it back unchanged is a
1092        // no-op" means on this side of the call: nothing is added, dropped or
1093        // reshaped between the two.
1094        let printed = "{\n  \"version\": \"0.0.1\",\n  \"services\": {}\n}\n";
1095        let read = parse_config(printed).expect("the client printed JSON");
1096        let file = config_file(&read).expect("the document is written");
1097        let written: Value =
1098            serde_json::from_slice(&std::fs::read(file.path()).expect("the file reads back"))
1099                .unwrap();
1100        assert_eq!(written, read);
1101    }
1102
1103    #[test]
1104    fn a_document_the_client_did_not_print_is_reported_rather_than_guessed_at() {
1105        let error = parse_config("Serve is not configured\n").expect_err("that is not JSON");
1106        assert_eq!(error.code, ErrorCode::CliFailed);
1107    }
1108
1109    // -- the internet ---------------------------------------------------------
1110
1111    #[tokio::test]
1112    async fn publishing_to_the_internet_says_so_in_the_answer() {
1113        let (public, _) = against(
1114            Reply::ok(SERVE_OUTPUT),
1115            |ctx, p| async move { funnel_set(&ctx, p).await },
1116            FunnelSetParams {
1117                target: "3000".to_owned(),
1118                ..FunnelSetParams::default()
1119            },
1120        )
1121        .await;
1122        assert_eq!(public["public"], json!(true));
1123
1124        let (tailnet_only, _) = against(
1125            Reply::ok(SERVE_OUTPUT),
1126            |ctx, p| async move { serve_set(&ctx, p).await },
1127            ServeSetParams {
1128                target: "3000".to_owned(),
1129                ..ServeSetParams::default()
1130            },
1131        )
1132        .await;
1133        assert_eq!(tailnet_only["public"], json!(false));
1134    }
1135
1136    #[tokio::test]
1137    async fn a_funnel_that_the_tailnet_has_not_enabled_hands_back_how_to_enable_it() {
1138        // The client does not fail here: it prints the enrolment URL and waits
1139        // for ever, so the bound on the call is what turns it into an answer.
1140        let backend = Arc::new(StubBackend::always(Reply::hung_after(
1141            "Funnel is not enabled on your tailnet.\n\
1142             To enable, visit: https://login.example.com/f/deadbeef\n",
1143        )));
1144        let ctx = context(Arc::clone(&backend));
1145        let error = funnel_set(
1146            &ctx,
1147            FunnelSetParams {
1148                target: "3000".to_owned(),
1149                ..FunnelSetParams::default()
1150            },
1151        )
1152        .await
1153        .expect_err("a client that never returns is not a success");
1154        assert_eq!(error.code, ErrorCode::Timeout);
1155        assert!(
1156            error
1157                .message
1158                .contains("https://login.example.com/f/deadbeef"),
1159            "the answer should carry what it was waiting on: {}",
1160            error.message
1161        );
1162    }
1163
1164    #[tokio::test]
1165    async fn stopping_a_funnel_takes_the_endpoint_off_the_internet() {
1166        let (answer, argv) = against(
1167            Reply::ok(""),
1168            |ctx, p| async move { funnel_off(&ctx, p).await },
1169            FunnelOffParams {
1170                https: Some(8443),
1171                ..FunnelOffParams::default()
1172            },
1173        )
1174        .await;
1175        assert_eq!(
1176            only(&argv),
1177            ["funnel", "--bg=true", "--yes=true", "--https=8443", "off"]
1178        );
1179        assert_eq!(answer["public"], json!(false));
1180    }
1181
1182    // -- the shape of the toolset ---------------------------------------------
1183
1184    #[test]
1185    fn funnel_is_out_of_reach_until_the_destructive_tier_is_allowed() {
1186        // The acceptance criterion the tier model exists for: publishing to
1187        // the internet is never something a write-tier session can do.
1188        for name in ["tailscale_funnel_set", "tailscale_funnel_off"] {
1189            let entry = entries()
1190                .into_iter()
1191                .find(|e| e.meta.name == name)
1192                .expect("the tool is declared");
1193            assert_eq!(
1194                entry.meta.tier,
1195                Tier::Destructive,
1196                "`{name}` publishes to or unpublishes from the internet"
1197            );
1198        }
1199    }
1200
1201    #[test]
1202    fn removing_everything_asks_first() {
1203        for name in [
1204            "tailscale_serve_reset",
1205            "tailscale_serve_clear",
1206            "tailscale_funnel_set",
1207        ] {
1208            let entry = entries()
1209                .into_iter()
1210                .find(|e| e.meta.name == name)
1211                .expect("the tool is declared");
1212            assert!(
1213                entry.meta.requires_confirmation,
1214                "`{name}` should not act without being asked twice"
1215            );
1216        }
1217    }
1218}