1use 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 tailscale_serve_set => ServeSetParams, serve_set,
40 toolset: LocalServe, tier: Write;
41
42 tailscale_serve_off => ServeOffParams, serve_off,
46 toolset: LocalServe, tier: Write;
47
48 tailscale_serve_reset => NoParams, serve_reset,
52 toolset: LocalServe, tier: Destructive, confirm: true;
53
54 tailscale_serve_drain => ServiceParams, serve_drain,
58 toolset: LocalServe, tier: Write, since: "1.90";
59
60 tailscale_serve_clear => ServiceParams, serve_clear,
65 toolset: LocalServe, tier: Destructive, confirm: true, since: "1.90";
66
67 tailscale_serve_advertise => ServiceParams, serve_advertise,
71 toolset: LocalServe, tier: Write, since: "1.90";
72
73 tailscale_serve_get_config => GetConfigParams, serve_get_config,
76 toolset: LocalServe, tier: Read, idempotent: true, since: "1.90";
77
78 tailscale_serve_set_config => SetConfigParams, serve_set_config,
82 toolset: LocalServe, tier: Write, idempotent: true, since: "1.90";
83
84 tailscale_funnel_set => FunnelSetParams, funnel_set,
92 toolset: LocalServe, tier: Destructive, confirm: true;
93
94 tailscale_funnel_off => FunnelOffParams, funnel_off,
98 toolset: LocalServe, tier: Destructive;
99}
100
101const BACKGROUND: &str = "--bg=true";
108
109const NO_PROMPT: &str = "--yes=true";
114
115const OFF: &str = "off";
117
118#[derive(Debug, Deserialize, JsonSchema)]
120pub struct NoParams {}
121
122macro_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 #[serde(default)]
139 pub https: Option<u16>,
140 #[serde(default)]
142 pub tcp: Option<u16>,
143 #[serde(default)]
146 pub tls_terminated_tcp: Option<u16>,
147 #[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 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 ServeSetParams {
181 target: String,
185 http: Option<u16>,
188 service: Option<String>,
191 tun: Option<bool>,
194 accept_app_caps: Option<Vec<String>>,
197 proxy_protocol: Option<u8>,
200 }
201}
202
203endpoint_params! {
204 ServeOffParams {
206 http: Option<u16>,
208 service: Option<String>,
210 }
211}
212
213endpoint_params! {
214 FunnelSetParams {
216 target: String,
220 proxy_protocol: Option<u8>,
223 }
224}
225
226endpoint_params! {
227 FunnelOffParams {}
229}
230
231#[derive(Debug, Deserialize, JsonSchema)]
233pub struct ServiceParams {
234 pub service: String,
237}
238
239fn 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
264fn 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
271fn describe(endpoint: Option<(&str, u16)>) -> Option<String> {
273 endpoint.map(|(scheme, port)| format!("{scheme}:{port}"))
274}
275
276fn 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
289fn 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
300fn 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#[derive(Debug, Serialize, JsonSchema)]
328pub struct HandlerReport {
329 pub target: String,
331 #[serde(skip_serializing_if = "Option::is_none")]
334 pub endpoint: Option<String>,
335 #[serde(skip_serializing_if = "Option::is_none")]
337 pub path: Option<String>,
338 #[serde(skip_serializing_if = "Option::is_none")]
341 pub service: Option<String>,
342 pub public: bool,
345 #[serde(skip_serializing_if = "Option::is_none")]
347 pub url: Option<String>,
348 #[serde(skip_serializing_if = "Option::is_none")]
351 pub printed: Option<String>,
352}
353
354#[derive(Debug, Serialize, JsonSchema)]
356pub struct ScopeReport {
357 pub scope: String,
359 pub outcome: String,
361 #[serde(skip_serializing_if = "Option::is_none")]
363 pub note: Option<String>,
364}
365
366#[derive(Debug, Serialize, JsonSchema)]
368pub struct ConfigReport {
369 pub scope: String,
371 pub configuration: Value,
374 #[serde(skip_serializing_if = "Option::is_none")]
376 pub note: Option<String>,
377}
378
379async 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 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
463async 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(¶ms.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#[derive(Debug, Deserialize, JsonSchema)]
527pub struct GetConfigParams {
528 #[serde(default)]
530 pub service: Option<String>,
531 #[serde(default)]
533 pub all: bool,
534}
535
536#[derive(Debug, Deserialize, JsonSchema)]
537pub struct SetConfigParams {
538 pub configuration: Value,
541 #[serde(default)]
544 pub service: Option<String>,
545 #[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 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 let file = config_file(¶ms.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
588fn 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
598fn 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
615async 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
663fn 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 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 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 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 fn only(argv: &[Vec<String>]) -> &[String] {
723 assert_eq!(argv.len(), 1, "one command should have run: {argv:?}");
724 &argv[0]
725 }
726
727 #[tokio::test]
730 async fn everything_that_can_hold_the_terminal_lets_it_go() {
731 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 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 #[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 #[tokio::test]
1010 async fn each_service_command_passes_the_service_and_nothing_else() {
1011 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 #[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 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 #[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 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 #[test]
1185 fn funnel_is_out_of_reach_until_the_destructive_tier_is_allowed() {
1186 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}