1use std::collections::BTreeSet;
12use std::net::{SocketAddr, ToSocketAddrs};
13use std::path::PathBuf;
14
15use clap::Parser;
16
17use crate::gating::{ConfigError, Preset, apply_toolset_modifiers};
18use crate::meta::{Surface, Tier, Toolset};
19
20pub const DEFAULT_MAX_RESULT_BYTES: usize = 1 << 20;
22
23pub const DEFAULT_LOG_FILTER: &str = "warn,tailscale_mcp=info";
25
26const SDK_CAP: &str = "rmcp=info";
38
39pub fn bounded_log_filter(requested: &str) -> String {
45 if requested.split(',').any(|directive| {
46 directive
47 .split('=')
48 .next()
49 .is_some_and(|target| target.trim() == "rmcp")
50 }) {
51 return requested.to_owned();
52 }
53 format!("{requested},{SDK_CAP}")
54}
55
56pub const PRESET_ENV: &str = "TAILSCALE_MCP_PRESET";
57pub const TOOLSETS_ENV: &str = "TAILSCALE_MCP_TOOLSETS";
58pub const ALLOW_WRITE_ENV: &str = "TAILSCALE_MCP_ALLOW_WRITE";
59pub const ALLOW_DESTRUCTIVE_ENV: &str = "TAILSCALE_MCP_ALLOW_DESTRUCTIVE";
60pub const NO_LOCAL_ENV: &str = "TAILSCALE_MCP_NO_LOCAL";
61pub const NO_TAILNET_ENV: &str = "TAILSCALE_MCP_NO_TAILNET";
62pub const CLI_PATH_ENV: &str = "TAILSCALE_MCP_CLI_PATH";
63pub const MAX_RESULT_BYTES_ENV: &str = "TAILSCALE_MCP_MAX_RESULT_BYTES";
64pub const LOG_ENV: &str = "TAILSCALE_MCP_LOG";
65pub const HTTP_TOKEN_ENV: &str = "TAILSCALE_MCP_HTTP_TOKEN";
70pub const HTTP_NO_AUTH_ENV: &str = "TAILSCALE_MCP_HTTP_NO_AUTH";
72pub const HTTP_STATELESS_ENV: &str = "TAILSCALE_MCP_HTTP_STATELESS";
74pub const API_BASE_URL_ENV: &str = "TAILSCALE_MCP_API_BASE_URL";
80
81pub const ENV_VARS: &[&str] = &[
84 PRESET_ENV,
85 TOOLSETS_ENV,
86 ALLOW_WRITE_ENV,
87 ALLOW_DESTRUCTIVE_ENV,
88 NO_LOCAL_ENV,
89 NO_TAILNET_ENV,
90 CLI_PATH_ENV,
91 MAX_RESULT_BYTES_ENV,
92 LOG_ENV,
93 HTTP_TOKEN_ENV,
94 HTTP_NO_AUTH_ENV,
95 HTTP_STATELESS_ENV,
96 API_BASE_URL_ENV,
97];
98
99const LONG_ABOUT: &str = "\
100An MCP server for Tailscale. The local node is driven through the `tailscale`
101command-line interface; the tailnet is driven through the control-plane REST
102API. Tools are read-only unless a tier is permitted, and hidden rather than
103refused when they are not.
104
105Every option below can also be set through the environment, which is what an
106MCP client configuration usually does. An option given on the command line
107wins over the matching variable.
108
109 TAILSCALE_MCP_PRESET --preset
110 TAILSCALE_MCP_TOOLSETS --toolsets
111 TAILSCALE_MCP_ALLOW_WRITE --allow-write
112 TAILSCALE_MCP_ALLOW_DESTRUCTIVE --allow-destructive
113 TAILSCALE_MCP_NO_LOCAL --no-local
114 TAILSCALE_MCP_NO_TAILNET --no-tailnet
115 TAILSCALE_MCP_CLI_PATH --cli-path
116 TAILSCALE_MCP_MAX_RESULT_BYTES --max-result-bytes
117 TAILSCALE_MCP_LOG --log
118 TAILSCALE_MCP_HTTP_NO_AUTH --http-no-auth
119 TAILSCALE_MCP_HTTP_STATELESS --http-stateless
120
121--http serves over Streamable HTTP instead of stdio, on 127.0.0.1:8449 unless
122given an address, with --http-allow-host and --http-allow-origin to widen what
123it answers for. TAILSCALE_MCP_HTTP_TOKEN is the bearer token callers must
124present; it has no command-line form, because an argument is readable by every
125process on this machine. A bind anywhere but loopback needs either that variable
126or --http-no-auth.
127
128TAILSCALE_TAILNET names the tailnet the control-plane tools act on; without it
129they act on the one the credential belongs to. TAILSCALE_MCP_API_BASE_URL sends
130the control-plane calls somewhere other than https://api.tailscale.com. It is
131there so the test suite can reach a fake on this machine, and has no
132command-line form. An address is accepted only over https or to this machine,
133and never with a username or password in it.
134
135Credentials for the tailnet surface are read from TAILSCALE_API_KEY, or from
136TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET, or from
137TAILSCALE_OAUTH_JWT_FILE, in that order. They have no command-line form: a
138secret on an argument list is visible to every process on the machine.";
139
140#[derive(Debug, Clone, Default, Parser)]
142#[command(name = "tailscale-mcp", version, about, long_about = LONG_ABOUT)]
143pub struct Cli {
144 #[arg(long, global = true, value_name = "NAME")]
146 pub preset: Option<String>,
147
148 #[arg(long, global = true, value_name = "LIST", allow_hyphen_values = true)]
151 pub toolsets: Option<String>,
152
153 #[arg(long, global = true)]
155 pub allow_write: bool,
156
157 #[arg(long, global = true)]
159 pub allow_destructive: bool,
160
161 #[arg(long, global = true)]
163 pub no_local: bool,
164
165 #[arg(long, global = true)]
167 pub no_tailnet: bool,
168
169 #[arg(long, global = true, value_name = "PATH")]
171 pub cli_path: Option<PathBuf>,
172
173 #[arg(long, global = true, value_name = "BYTES")]
175 pub max_result_bytes: Option<usize>,
176
177 #[arg(long, global = true, value_name = "FILTER")]
179 pub log: Option<String>,
180
181 #[arg(long, value_name = "ADDR", num_args = 0..=1, default_missing_value = crate::http::DEFAULT_BIND)]
184 pub http: Option<String>,
185
186 #[arg(long)]
189 pub http_no_auth: bool,
190
191 #[arg(long, value_name = "HOST")]
194 pub http_allow_host: Vec<String>,
195
196 #[arg(long, value_name = "ORIGIN")]
199 pub http_allow_origin: Vec<String>,
200
201 #[arg(long)]
205 pub http_stateless: bool,
206
207 #[command(subcommand)]
209 pub command: Option<Command>,
210}
211
212#[derive(Debug, Clone, clap::Subcommand)]
214pub enum Command {
215 Diagnose {
219 #[arg(long)]
221 json: bool,
222 },
223 Tools {
225 #[arg(long)]
227 json: bool,
228 },
229 Version,
231 Setup {
233 #[arg(value_enum)]
235 client: Client,
236 },
237 Policy {
239 #[command(subcommand)]
240 action: PolicyCommand,
241 },
242}
243
244#[derive(Debug, Clone, clap::Subcommand)]
246pub enum PolicyCommand {
247 Check {
249 file: PathBuf,
251 },
252 Deploy {
255 file: PathBuf,
257 },
258}
259
260#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
266pub enum Client {
267 ClaudeCode,
268 ClaudeDesktop,
269 Vscode,
270 Cursor,
271 Zed,
272}
273
274#[derive(Debug, Clone)]
276pub struct HttpConfig {
277 pub bind: SocketAddr,
279 pub token: Option<tailscale_rest::Secret>,
281 pub allow_hosts: Vec<String>,
283 pub allow_origins: Vec<String>,
285 pub stateful: bool,
287}
288
289#[derive(Debug, Clone)]
291pub struct Config {
292 pub preset: Preset,
294 pub toolsets: BTreeSet<Toolset>,
296 pub max_tier: Tier,
298 pub disabled: BTreeSet<Surface>,
301 pub cli_path: Option<PathBuf>,
302 pub max_result_bytes: usize,
303 pub api_base_url: String,
307 pub tailnet: String,
310 pub log_filter: String,
311 pub http: Option<HttpConfig>,
313}
314
315impl Config {
316 pub fn resolve(cli: Cli) -> Result<Self, ConfigError> {
318 Self::resolve_with(cli, |key| std::env::var(key).ok())
319 }
320
321 pub fn resolve_with(
323 cli: Cli,
324 source: impl Fn(&str) -> Option<String>,
325 ) -> Result<Self, ConfigError> {
326 let get = |key: &str| {
327 source(key)
328 .map(|v| v.trim().to_owned())
329 .filter(|v| !v.is_empty())
330 };
331 let flag = |set: bool, key: &str| -> Result<bool, ConfigError> {
332 if set {
333 return Ok(true);
334 }
335 match get(key) {
336 None => Ok(false),
337 Some(raw) => parse_bool(&raw).ok_or_else(|| ConfigError::InvalidValue {
338 setting: key.to_owned(),
339 value: raw,
340 expected: "a boolean (1, true, yes, on, or their negatives)",
341 }),
342 }
343 };
344
345 let preset = match cli.preset.or_else(|| get(PRESET_ENV)) {
346 Some(name) => Preset::parse(&name)?,
347 None => Preset::default(),
348 };
349
350 let spec = cli.toolsets.or_else(|| get(TOOLSETS_ENV));
351 let toolsets = match spec.as_deref() {
352 Some(spec) => apply_toolset_modifiers(preset.toolsets(), spec)?,
353 None => preset.toolsets(),
354 };
355
356 let allow_destructive = flag(cli.allow_destructive, ALLOW_DESTRUCTIVE_ENV)?;
357 let allow_write = allow_destructive || flag(cli.allow_write, ALLOW_WRITE_ENV)?;
358 let max_tier = match (allow_destructive, allow_write) {
359 (true, _) => Tier::Destructive,
360 (_, true) => Tier::Write,
361 _ => Tier::Read,
362 };
363
364 let mut disabled = BTreeSet::new();
365 if flag(cli.no_local, NO_LOCAL_ENV)? {
366 disabled.insert(Surface::Local);
367 }
368 if flag(cli.no_tailnet, NO_TAILNET_ENV)? {
369 disabled.insert(Surface::Tailnet);
370 }
371
372 let max_result_bytes = match cli.max_result_bytes {
373 Some(0) => {
378 return Err(ConfigError::InvalidValue {
379 setting: "--max-result-bytes".to_owned(),
380 value: "0".to_owned(),
381 expected: "a positive number of bytes",
382 });
383 }
384 Some(n) => n,
385 None => match get(MAX_RESULT_BYTES_ENV) {
386 Some(raw) => raw
387 .parse::<usize>()
388 .ok()
389 .filter(|n| *n > 0)
390 .ok_or_else(|| ConfigError::InvalidValue {
391 setting: MAX_RESULT_BYTES_ENV.to_owned(),
392 value: raw,
393 expected: "a positive number of bytes",
394 })?,
395 None => DEFAULT_MAX_RESULT_BYTES,
396 },
397 };
398
399 let http = match cli.http {
400 None => None,
401 Some(address) => Some(checked_http(
402 HttpConfig {
403 bind: http_bind(&address)?,
404 token: get(HTTP_TOKEN_ENV).map(tailscale_rest::Secret::new),
409 allow_hosts: cli.http_allow_host,
410 allow_origins: cli.http_allow_origin,
411 stateful: !flag(cli.http_stateless, HTTP_STATELESS_ENV)?,
412 },
413 &address,
414 flag(cli.http_no_auth, HTTP_NO_AUTH_ENV)?,
415 )?),
416 };
417
418 Ok(Self {
419 preset,
420 toolsets,
421 max_tier,
422 disabled,
423 cli_path: cli
424 .cli_path
425 .or_else(|| get(CLI_PATH_ENV).map(PathBuf::from)),
426 max_result_bytes,
427 api_base_url: get(API_BASE_URL_ENV)
428 .unwrap_or_else(|| tailscale_rest::DEFAULT_BASE_URL.to_owned()),
429 tailnet: tailscale_rest::credentials::tailnet_from_source(&source),
430 log_filter: bounded_log_filter(
431 &cli.log
432 .or_else(|| get(LOG_ENV))
433 .unwrap_or_else(|| DEFAULT_LOG_FILTER.to_owned()),
434 ),
435 http,
436 })
437 }
438
439 pub fn is_disabled(&self, surface: Surface) -> bool {
441 self.disabled.contains(&surface)
442 }
443
444 pub fn changed_settings(&self) -> Vec<(&'static str, String)> {
456 let mut chosen = Vec::new();
457 if self.preset != Preset::default() {
458 chosen.push((PRESET_ENV, self.preset.as_str().to_owned()));
459 }
460 if self.toolsets != self.preset.toolsets() {
461 chosen.push((
462 TOOLSETS_ENV,
463 self.toolsets
464 .iter()
465 .map(|toolset| toolset.as_str())
466 .collect::<Vec<_>>()
467 .join(","),
468 ));
469 }
470 match self.max_tier {
471 Tier::Destructive => chosen.push((ALLOW_DESTRUCTIVE_ENV, "true".to_owned())),
472 Tier::Write => chosen.push((ALLOW_WRITE_ENV, "true".to_owned())),
473 Tier::Read => {}
474 }
475 if self.is_disabled(Surface::Local) {
476 chosen.push((NO_LOCAL_ENV, "true".to_owned()));
477 }
478 if self.is_disabled(Surface::Tailnet) {
479 chosen.push((NO_TAILNET_ENV, "true".to_owned()));
480 }
481 if let Some(path) = &self.cli_path {
482 chosen.push((CLI_PATH_ENV, path.display().to_string()));
483 }
484 if self.max_result_bytes != DEFAULT_MAX_RESULT_BYTES {
485 chosen.push((MAX_RESULT_BYTES_ENV, self.max_result_bytes.to_string()));
486 }
487 if self.log_filter != bounded_log_filter(DEFAULT_LOG_FILTER) {
491 chosen.push((LOG_ENV, self.log_filter.clone()));
492 }
493 chosen
494 }
495}
496
497pub const NOT_IN_A_SNIPPET: &[&str] = &[
505 HTTP_TOKEN_ENV,
506 HTTP_NO_AUTH_ENV,
507 HTTP_STATELESS_ENV,
508 API_BASE_URL_ENV,
509];
510
511fn checked_http(
520 settings: HttpConfig,
521 address: &str,
522 no_auth: bool,
523) -> Result<HttpConfig, ConfigError> {
524 if no_auth {
525 return Ok(HttpConfig {
526 token: None,
527 ..settings
528 });
529 }
530 if settings.token.is_none() && !settings.bind.ip().is_loopback() {
534 return Err(ConfigError::InvalidValue {
535 setting: "--http".to_owned(),
536 value: address.to_owned(),
537 expected: "a loopback address \u{2014} set a token in \
543 TAILSCALE_MCP_HTTP_TOKEN, or pass --http-no-auth to say \
544 the address really should answer anyone who asks",
545 });
546 }
547 Ok(settings)
548}
549
550fn http_bind(address: &str) -> Result<SocketAddr, ConfigError> {
552 address
553 .to_socket_addrs()
554 .ok()
555 .and_then(|mut found| found.next())
556 .ok_or_else(|| ConfigError::InvalidValue {
557 setting: "--http".to_owned(),
558 value: address.to_owned(),
559 expected: "an address and port, such as 127.0.0.1:8449",
560 })
561}
562
563fn parse_bool(raw: &str) -> Option<bool> {
565 match raw.trim().to_ascii_lowercase().as_str() {
566 "1" | "true" | "yes" | "on" => Some(true),
567 "0" | "false" | "no" | "off" | "" => Some(false),
568 _ => None,
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use std::collections::HashMap;
575
576 use super::*;
577
578 fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
579 let map: HashMap<String, String> = pairs
580 .iter()
581 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
582 .collect();
583 move |key| map.get(key).cloned()
584 }
585
586 fn resolve(cli: Cli, pairs: &[(&str, &str)]) -> Result<Config, ConfigError> {
587 Config::resolve_with(cli, env(pairs))
588 }
589
590 #[test]
591 fn the_defaults_are_the_core_preset_and_read_only() {
592 let config = resolve(Cli::default(), &[]).expect("defaults resolve");
593 assert_eq!(config.preset, Preset::Core);
594 assert_eq!(config.toolsets, Preset::Core.toolsets());
595 assert_eq!(config.max_tier, Tier::Read);
596 assert!(config.disabled.is_empty());
597 assert_eq!(config.max_result_bytes, DEFAULT_MAX_RESULT_BYTES);
598 }
599
600 #[test]
601 fn the_environment_configures_a_client_that_cannot_pass_arguments() {
602 let config = resolve(
603 Cli::default(),
604 &[
605 (PRESET_ENV, "full"),
606 (ALLOW_WRITE_ENV, "true"),
607 (NO_TAILNET_ENV, "1"),
608 (MAX_RESULT_BYTES_ENV, "4096"),
609 (LOG_ENV, "debug"),
610 ],
611 )
612 .expect("resolves");
613 assert_eq!(config.preset, Preset::Full);
614 assert_eq!(config.max_tier, Tier::Write);
615 assert!(config.is_disabled(Surface::Tailnet));
616 assert!(!config.is_disabled(Surface::Local));
617 assert_eq!(config.max_result_bytes, 4096);
618 assert_eq!(config.log_filter, "debug,rmcp=info");
619 }
620
621 #[test]
622 fn the_command_line_wins_over_the_environment() {
623 let cli = Cli {
624 preset: Some("minimal".to_owned()),
625 log: Some("trace".to_owned()),
626 max_result_bytes: Some(1024),
627 ..Cli::default()
628 };
629 let config = resolve(
630 cli,
631 &[
632 (PRESET_ENV, "full"),
633 (LOG_ENV, "debug"),
634 (MAX_RESULT_BYTES_ENV, "4096"),
635 ],
636 )
637 .expect("resolves");
638 assert_eq!(config.preset, Preset::Minimal);
639 assert_eq!(config.log_filter, "trace,rmcp=info");
640 assert_eq!(config.max_result_bytes, 1024);
641 }
642
643 #[test]
644 fn a_flag_set_on_the_command_line_is_not_undone_by_the_environment() {
645 let cli = Cli {
648 allow_write: true,
649 ..Cli::default()
650 };
651 let config = resolve(cli, &[(ALLOW_WRITE_ENV, "false")]).expect("resolves");
652 assert_eq!(config.max_tier, Tier::Write);
653 }
654
655 #[test]
656 fn permitting_destruction_permits_writing() {
657 let cli = Cli {
658 allow_destructive: true,
659 ..Cli::default()
660 };
661 let config = resolve(cli, &[]).expect("resolves");
662 assert_eq!(config.max_tier, Tier::Destructive);
663 assert!(config.max_tier >= Tier::Write);
664 }
665
666 #[test]
667 fn toolsets_adjust_the_preset() {
668 let cli = Cli {
669 toolsets: Some("+local-debug,-tailnet-dns".to_owned()),
670 ..Cli::default()
671 };
672 let config = resolve(cli, &[]).expect("resolves");
673 assert!(config.toolsets.contains(&Toolset::LocalDebug));
674 assert!(!config.toolsets.contains(&Toolset::TailnetDns));
675 assert!(config.toolsets.contains(&Toolset::LocalStatus));
676 }
677
678 #[test]
679 fn a_bare_toolset_list_replaces_the_preset() {
680 let config = resolve(
681 Cli::default(),
682 &[(PRESET_ENV, "full"), (TOOLSETS_ENV, "local-status")],
683 )
684 .expect("resolves");
685 assert_eq!(config.toolsets, BTreeSet::from([Toolset::LocalStatus]));
686 assert_eq!(config.preset, Preset::Full);
689 }
690
691 #[test]
692 fn a_setting_that_cannot_be_understood_stops_the_server() {
693 let cases: [(&str, &str); 4] = [
694 (PRESET_ENV, "everything"),
695 (TOOLSETS_ENV, "not-a-toolset"),
696 (ALLOW_WRITE_ENV, "perhaps"),
697 (MAX_RESULT_BYTES_ENV, "0"),
698 ];
699 for (key, value) in cases {
700 let err = resolve(Cli::default(), &[(key, value)])
701 .expect_err("{key}={value} should be refused");
702 let message = err.to_string();
703 assert!(
704 message.contains(value) || message.contains("unknown"),
705 "{key}={value} produced {message}"
706 );
707 }
708 }
709
710 #[test]
711 fn a_blank_variable_is_the_same_as_an_unset_one() {
712 let config = resolve(
713 Cli::default(),
714 &[(PRESET_ENV, " "), (LOG_ENV, ""), (TOOLSETS_ENV, " ")],
715 )
716 .expect("resolves");
717 assert_eq!(config.preset, Preset::Core);
718 assert_eq!(config.log_filter, bounded_log_filter(DEFAULT_LOG_FILTER));
719 }
720
721 #[test]
722 fn turning_up_the_volume_does_not_turn_on_the_wire() {
723 assert_eq!(bounded_log_filter("debug"), "debug,rmcp=info");
726 assert_eq!(
727 bounded_log_filter(DEFAULT_LOG_FILTER),
728 "warn,tailscale_mcp=info,rmcp=info"
729 );
730 assert_eq!(bounded_log_filter("info,rmcp=trace"), "info,rmcp=trace");
732 assert_eq!(bounded_log_filter("rmcp=debug"), "rmcp=debug");
733 }
734
735 #[test]
736 fn the_boolean_spellings_people_write_are_all_understood() {
737 for raw in ["1", "true", "TRUE", "yes", "on"] {
738 let config = resolve(Cli::default(), &[(ALLOW_WRITE_ENV, raw)]).expect("resolves");
739 assert_eq!(config.max_tier, Tier::Write, "{raw}");
740 }
741 for raw in ["0", "false", "no", "off"] {
742 let config = resolve(Cli::default(), &[(ALLOW_WRITE_ENV, raw)]).expect("resolves");
743 assert_eq!(config.max_tier, Tier::Read, "{raw}");
744 }
745 }
746
747 const SOURCE: &str = include_str!("config.rs");
750
751 #[test]
752 fn the_documented_variables_are_the_ones_that_are_read() {
753 for var in ENV_VARS {
759 assert!(
760 LONG_ABOUT.contains(var),
761 "{var} is read but not documented in the help"
762 );
763 }
764
765 let declared: std::collections::HashMap<&str, &str> = SOURCE
767 .lines()
768 .filter_map(|line| {
769 let line = line.trim().strip_prefix("pub const ")?;
770 let (name, rest) = line.split_once(": &str = \"")?;
771 name.ends_with("_ENV")
772 .then(|| (name, rest.split('"').next().unwrap_or_default()))
773 })
774 .collect();
775 assert!(
776 declared.len() >= ENV_VARS.len(),
777 "every listed variable should have a declaration to be found"
778 );
779
780 let resolution = SOURCE
782 .split_once("pub fn resolve_with(")
783 .and_then(|(_, rest)| rest.split_once("\n /// Whether the operator switched"))
784 .map_or(SOURCE, |(body, _)| body);
785 for (name, var) in &declared {
786 let read = resolution.contains(&format!("{name})"))
787 || resolution.contains(&format!("{name},"));
788 if read {
789 assert!(
790 ENV_VARS.contains(var),
791 "{name} is read by the resolution but is not in ENV_VARS, so nothing \
792 documents it and the diagnosis subcommand will not check it"
793 );
794 }
795 }
796 }
797
798 #[test]
799 fn a_leading_minus_in_a_toolset_list_is_not_read_as_a_flag() {
800 let cli = Cli::try_parse_from(["tailscale-mcp", "--toolsets", "-tailnet-dns"])
801 .expect("a removal parses");
802 assert_eq!(cli.toolsets.as_deref(), Some("-tailnet-dns"));
803 }
804
805 #[test]
806 fn the_command_line_offers_no_way_to_pass_a_secret() {
807 use clap::CommandFactory as _;
808 for arg in Cli::command().get_arguments() {
809 let name = arg.get_id().as_str();
810 assert!(
811 !["key", "secret", "token", "password"]
812 .iter()
813 .any(|bad| name.contains(bad)),
814 "`--{name}` would put a secret on the argument list"
815 );
816 }
817 }
818
819 #[test]
827 fn an_invalid_value_says_what_the_value_should_have_been() {
828 let phrases = [
829 checked_http(
830 HttpConfig {
831 bind: "0.0.0.0:8449".parse().expect("an address"),
832 token: None,
833 allow_hosts: Vec::new(),
834 allow_origins: Vec::new(),
835 stateful: true,
836 },
837 "0.0.0.0:8449",
838 false,
839 )
840 .expect_err("an unauthenticated public bind is refused"),
841 http_bind("not-an-address").expect_err("a bad address is refused"),
842 ];
843 for error in phrases {
844 let rendered = error.to_string();
845 let (_, expected) = rendered
846 .split_once("which is not ")
847 .unwrap_or_else(|| panic!("the template should be intact: {rendered}"));
848 let clause = expected
851 .split(['\u{2014}', ';'])
852 .next()
853 .expect("a first clause");
854 assert!(
855 !clause.contains(" or "),
856 "`which is not {clause}` offers alternatives where it should name \
857 one thing; put the remedies after a dash: {rendered}"
858 );
859 assert!(
860 !clause.contains("--") && !clause.contains("TAILSCALE_"),
861 "`which is not {clause}` says the value is not a flag or a variable, \
862 which is not what failed: {rendered}"
863 );
864 }
865 }
866}