1use std::net::IpAddr;
10use std::path::Path;
11
12use serde::{Deserialize, Serialize};
13
14use crate::api::{CorsConfig, SecurityConfig, ServerConfig};
15use crate::cli::Args;
16use crate::security::{AuthConfig, CapabilitySet, RateLimitConfig};
17use crate::tunnel::{Cloudflared, CustomCommand, TunnelProvider};
18
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
21#[serde(default)]
22pub struct Config {
23 pub server: ServerSection,
25 pub security: SecuritySection,
27 pub transport: TransportSection,
29 pub logging: LoggingSection,
31}
32
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "kebab-case")]
40pub enum TransportMode {
41 #[default]
43 None,
44 Cloudflared,
46 Command,
48}
49
50#[derive(Debug, Clone, Default, Serialize, Deserialize)]
52#[serde(default)]
53pub struct TransportSection {
54 pub mode: TransportMode,
56 pub command: Option<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(default)]
63pub struct ServerSection {
64 pub host: String,
66 pub port: u16,
68 pub graceful_shutdown: bool,
70}
71
72impl Default for ServerSection {
73 fn default() -> Self {
74 Self {
75 host: "127.0.0.1".to_string(),
76 port: 3000,
77 graceful_shutdown: true,
78 }
79 }
80}
81
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84#[serde(default)]
85pub struct SecuritySection {
86 pub auth: AuthSection,
88 pub rate_limit: RateLimitSection,
90 pub cors: CorsSection,
92}
93
94#[derive(Debug, Clone, Default, Serialize, Deserialize)]
96#[serde(default)]
97pub struct CorsSection {
98 pub allow_any: bool,
101}
102
103#[derive(Debug, Clone, Default, Serialize, Deserialize)]
105#[serde(default)]
106pub struct AuthSection {
107 pub enabled: bool,
109 pub api_keys: Vec<String>,
111 pub capabilities: Vec<String>,
113 pub preset: Option<String>,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
119#[serde(default)]
120pub struct RateLimitSection {
121 pub enabled: bool,
123 pub requests_per_window: u32,
125 pub window_secs: u64,
127}
128
129impl Default for RateLimitSection {
130 fn default() -> Self {
131 Self {
132 enabled: true,
133 requests_per_window: 100,
134 window_secs: 60,
135 }
136 }
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(default)]
142pub struct LoggingSection {
143 pub level: String,
145}
146
147impl Default for LoggingSection {
148 fn default() -> Self {
149 Self {
150 level: "info".to_string(),
151 }
152 }
153}
154
155impl Config {
156 pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
158 let content = std::fs::read_to_string(path).map_err(ConfigError::Io)?;
159 serde_json::from_str(&content).map_err(ConfigError::Json)
160 }
161
162 pub fn apply_env(&mut self) {
164 if let Ok(host) = std::env::var("SHELL_TUNNEL_HOST") {
165 self.server.host = host;
166 }
167
168 if let Ok(port) = std::env::var("SHELL_TUNNEL_PORT") {
169 if let Ok(port) = port.parse() {
170 self.server.port = port;
171 }
172 }
173
174 if let Ok(key) = std::env::var("SHELL_TUNNEL_API_KEY") {
175 if !key.is_empty() {
176 self.security.auth.enabled = true;
177 if !self.security.auth.api_keys.contains(&key) {
178 self.security.auth.api_keys.push(key);
179 }
180 }
181 }
182
183 if let Ok(level) = std::env::var("SHELL_TUNNEL_LOG_LEVEL") {
184 self.logging.level = level;
185 } else if let Ok(level) = std::env::var("RUST_LOG") {
186 self.logging.level = level;
187 }
188 }
189
190 pub fn apply_args(&mut self, args: &Args) {
206 if args.host_explicit {
207 self.server.host = args.host.to_string();
208 }
209 if args.port_explicit {
210 self.server.port = args.port;
211 }
212
213 if let Some(ref key) = args.api_key {
214 self.security.auth.enabled = true;
215 if !self.security.auth.api_keys.contains(key) {
216 self.security.auth.api_keys.push(key.clone());
217 }
218 }
219
220 if args.require_auth {
223 self.security.auth.enabled = true;
224 }
225
226 let scope_named = !args.capabilities.is_empty() || args.preset.is_some();
242 if scope_named {
243 self.security.auth.capabilities.clear();
244 self.security.auth.preset = None;
245 self.security.auth.enabled = true;
246 }
247 if !args.capabilities.is_empty() {
248 self.security.auth.capabilities = args.capabilities.clone();
249 }
250 if let Some(ref preset) = args.preset {
251 self.security.auth.preset = Some(preset.clone());
252 }
253
254 if args.no_auth {
255 self.security.auth.enabled = false;
256 }
257
258 if let Some(ref command) = args.tunnel_command {
261 self.transport.mode = TransportMode::Command;
262 self.transport.command = Some(command.clone());
263 } else if args.tunnel {
264 self.transport.mode = TransportMode::Cloudflared;
265 }
266
267 if args.no_rate_limit {
268 self.security.rate_limit.enabled = false;
269 }
270
271 if args.cors_allow_any {
272 self.security.cors.allow_any = true;
273 }
274
275 if let Some(ref level) = args.log_level {
276 self.logging.level = level.clone();
277 }
278 }
279
280 pub fn load(args: &Args) -> Result<Self, ConfigError> {
284 let mut config = Config::default();
286
287 if let Some(ref path) = args.config {
289 config = Config::from_file(path)?;
290 }
291
292 config.apply_env();
294
295 config.apply_args(args);
297
298 Ok(config)
299 }
300
301 pub fn allowed_hosts(&self, args: &Args, published: bool) -> Option<Vec<String>> {
310 let host: IpAddr = self.server.host.parse().ok()?;
311 if published || !host.is_loopback() {
312 return None;
313 }
314
315 let mut hosts = vec![
316 "localhost".to_string(),
317 "127.0.0.1".to_string(),
318 "::1".to_string(),
319 ];
320 hosts.extend(args.allow_hosts.iter().cloned());
321 Some(hosts)
322 }
323
324 pub fn tunnel_provider(&self) -> Result<Option<Box<dyn TunnelProvider>>, ConfigError> {
326 match self.transport.mode {
327 TransportMode::None => Ok(None),
328 TransportMode::Cloudflared => Ok(Some(Box::new(Cloudflared))),
329 TransportMode::Command => {
330 let command = self
331 .transport
332 .command
333 .as_deref()
334 .filter(|c| !c.trim().is_empty())
335 .ok_or(ConfigError::MissingTunnelCommand)?;
336 Ok(Some(Box::new(CustomCommand::new(command))))
337 }
338 }
339 }
340
341 pub fn posture(&self, tunnel_configured: bool, relay_attached: bool) -> Posture {
350 if tunnel_configured || relay_attached {
351 return Posture::Exposed;
352 }
353 match self.server.host.parse::<IpAddr>() {
354 Ok(ip) if ip.is_loopback() => Posture::Local,
355 _ => Posture::Exposed,
359 }
360 }
361
362 pub fn harden_for_public_exposure(
376 &mut self,
377 args: &Args,
378 ) -> Result<PublicExposure, ConfigError> {
379 if args.no_auth {
380 return Err(ConfigError::RemoteWithoutAuth);
381 }
382
383 self.security.auth.enabled = true;
384
385 let generated_key = self.ensure_api_key();
386
387 if self.security.auth.preset.is_none() && self.security.auth.capabilities.is_empty() {
399 self.security.auth.preset = Some("operator".to_string());
400 }
401
402 let mut warnings = Vec::new();
403 if !self.security.rate_limit.enabled {
407 warnings.push("rate limiting is disabled on a publicly reachable server".to_string());
408 }
409
410 Ok(PublicExposure {
411 generated_key,
412 warnings,
413 })
414 }
415
416 pub fn ensure_api_key(&mut self) -> Option<String> {
430 if !self.security.auth.enabled || !self.security.auth.api_keys.is_empty() {
431 return None;
432 }
433 let key = crate::security::generate_api_key();
434 self.security.auth.api_keys.push(key.clone());
435 Some(key)
436 }
437
438 pub fn to_server_config(&self) -> Result<ServerConfig, ConfigError> {
440 let host: IpAddr = self
441 .server
442 .host
443 .parse()
444 .map_err(|_| ConfigError::InvalidHost(self.server.host.clone()))?;
445
446 let mut security = if self.security.auth.enabled {
447 SecurityConfig::secure()
448 } else {
449 SecurityConfig::development()
450 };
451
452 security.auth = AuthConfig {
454 enabled: self.security.auth.enabled,
455 ..AuthConfig::default()
456 };
457
458 security.rate_limit = RateLimitConfig {
460 enabled: self.security.rate_limit.enabled,
461 max_requests: self.security.rate_limit.requests_per_window,
462 window: std::time::Duration::from_secs(self.security.rate_limit.window_secs),
463 max_tracked_ips: 10000,
464 };
465
466 security.cors = CorsConfig {
468 allow_any: self.security.cors.allow_any,
469 };
470
471 if let Some(capabilities) = resolve_capabilities(
473 self.security.auth.preset.as_deref(),
474 &self.security.auth.capabilities,
475 )? {
476 security = security.with_capabilities(capabilities);
477 }
478
479 for key in &self.security.auth.api_keys {
481 security = security.with_api_key(key);
482 }
483
484 let mut server_config = ServerConfig::new(host.to_string(), self.server.port);
485 server_config = server_config.with_security(security);
486
487 if !self.server.graceful_shutdown {
488 server_config = server_config.without_graceful_shutdown();
489 }
490
491 Ok(server_config)
492 }
493
494 pub fn resolved_capabilities(&self) -> Result<Option<CapabilitySet>, ConfigError> {
502 resolve_capabilities(
503 self.security.auth.preset.as_deref(),
504 &self.security.auth.capabilities,
505 )
506 }
507
508 pub fn log_filter(&self) -> &str {
510 &self.logging.level
511 }
512}
513
514fn resolve_capabilities(
520 preset: Option<&str>,
521 capabilities: &[String],
522) -> Result<Option<CapabilitySet>, ConfigError> {
523 if preset.is_none() && capabilities.is_empty() {
524 return Ok(None); }
526
527 let mut set = match preset {
528 Some(name) => crate::security::preset(name)
529 .ok_or_else(|| ConfigError::InvalidPreset(name.to_string()))?,
530 None => CapabilitySet::new(),
531 };
532 for capability in capabilities {
533 set.insert(capability.clone());
534 }
535 Ok(Some(set))
536}
537
538#[derive(Debug, Clone, Copy, PartialEq, Eq)]
544pub enum Posture {
545 Local,
547 Exposed,
549}
550
551#[derive(Debug, Clone, Default)]
553pub struct PublicExposure {
554 pub generated_key: Option<String>,
556 pub warnings: Vec<String>,
558}
559
560#[derive(Debug)]
562pub enum ConfigError {
563 Io(std::io::Error),
565 Json(serde_json::Error),
567 InvalidHost(String),
569 InvalidPreset(String),
571 RemoteWithoutAuth,
573 MissingTunnelCommand,
575}
576
577impl std::fmt::Display for ConfigError {
578 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
579 match self {
580 Self::Io(e) => write!(f, "failed to read config file: {}", e),
581 Self::Json(e) => write!(f, "failed to parse config file: {}", e),
582 Self::InvalidHost(host) => write!(f, "invalid host address: {}", host),
583 Self::InvalidPreset(name) if name == "read-only" => {
584 write!(
585 f,
586 "the 'read-only' preset was removed: it granted only session.read, so it could not read a file despite its name. Use file-read to read files, or capabilities session.read for the old behaviour — as --preset/--capabilities, or as security.auth.preset/security.auth.capabilities in a config file"
591 )
592 }
593 Self::InvalidPreset(name) => write!(
594 f,
595 "unknown role preset: '{}' (expected operator, file-write, file-read, or full-control)",
596 name
597 ),
598 Self::MissingTunnelCommand => write!(
599 f,
600 "transport.mode is \"command\" but transport.command is not set (or use --tunnel-command)"
601 ),
602 Self::RemoteWithoutAuth => write!(
603 f,
604 "--no-auth cannot be combined with a publicly reachable server: that would expose an unauthenticated shell. It is refused for a tunnel, a relay, and a non-loopback bind alike. Drop --no-auth (a key is generated for you), or bind loopback and drop the public path"
605 ),
606 }
607 }
608}
609
610impl std::error::Error for ConfigError {}
611
612#[cfg(test)]
613mod tests {
614 use super::*;
615 use std::io::Write;
616 use tempfile::NamedTempFile;
617
618 #[test]
619 fn test_default_config() {
620 let config = Config::default();
621 assert_eq!(config.server.host, "127.0.0.1");
622 assert_eq!(config.server.port, 3000);
623 assert!(!config.security.auth.enabled);
624 assert!(config.security.rate_limit.enabled);
625 }
626
627 #[test]
628 fn test_config_from_json() {
629 let json = r#"{
630 "server": {
631 "host": "0.0.0.0",
632 "port": 8080
633 },
634 "security": {
635 "auth": {
636 "enabled": true,
637 "api_keys": ["key1", "key2"]
638 }
639 }
640 }"#;
641
642 let mut file = NamedTempFile::new().unwrap();
643 file.write_all(json.as_bytes()).unwrap();
644
645 let config = Config::from_file(file.path()).unwrap();
646 assert_eq!(config.server.host, "0.0.0.0");
647 assert_eq!(config.server.port, 8080);
648 assert!(config.security.auth.enabled);
649 assert_eq!(config.security.auth.api_keys.len(), 2);
650 }
651
652 #[test]
653 fn test_config_partial_json() {
654 let json = r#"{
655 "server": {
656 "port": 9000
657 }
658 }"#;
659
660 let mut file = NamedTempFile::new().unwrap();
661 file.write_all(json.as_bytes()).unwrap();
662
663 let config = Config::from_file(file.path()).unwrap();
664 assert_eq!(config.server.host, "127.0.0.1"); assert_eq!(config.server.port, 9000);
666 }
667
668 #[test]
669 fn test_apply_args() {
670 let mut config = Config::default();
671 let args = Args {
672 host: "192.168.1.1".parse().unwrap(),
673 host_explicit: true,
679 port: 5000,
680 port_explicit: true,
681 api_key: Some("test-key".to_string()),
682 no_rate_limit: true,
683 ..Args::default()
684 };
685
686 config.apply_args(&args);
687
688 assert_eq!(config.server.host, "192.168.1.1");
689 assert_eq!(config.server.port, 5000);
690 assert!(config.security.auth.enabled);
691 assert!(config
692 .security
693 .auth
694 .api_keys
695 .contains(&"test-key".to_string()));
696 assert!(!config.security.rate_limit.enabled);
697 }
698
699 #[test]
708 fn a_configured_host_and_port_survive_when_no_flag_names_them() {
709 let mut config = Config::default();
710 config.server.host = "0.0.0.0".to_string();
714 config.server.port = 8080;
715
716 let nothing_passed = Args::default();
717 assert!(
718 !nothing_passed.port_explicit && !nothing_passed.host_explicit,
719 "the premise: no flag was given"
720 );
721 config.apply_args(¬hing_passed);
722
723 assert_eq!(config.server.host, "0.0.0.0");
724 assert_eq!(config.server.port, 8080);
725 }
726
727 #[test]
730 fn a_named_host_and_port_beat_the_configured_ones() {
731 let mut config = Config::default();
732 config.server.host = "0.0.0.0".to_string();
733 config.server.port = 8080;
734
735 config.apply_args(&Args {
736 host: "10.0.0.5".parse().expect("addr"),
737 host_explicit: true,
738 port: 9999,
739 port_explicit: true,
740 ..Args::default()
741 });
742
743 assert_eq!(config.server.host, "10.0.0.5");
744 assert_eq!(config.server.port, 9999);
745 }
746
747 #[test]
756 fn a_configured_non_loopback_host_now_decides_the_posture() {
757 let mut config = Config::default();
758 config.server.host = "0.0.0.0".to_string();
759 config.apply_args(&Args::default());
760
761 assert_eq!(
762 config.posture(false, false),
763 Posture::Exposed,
764 "a bind address that now takes effect must also be seen by the posture"
765 );
766 let server = config.to_server_config().expect("valid config");
767 assert_eq!(
768 server.host, "0.0.0.0",
769 "the posture and the listener must read the same field"
770 );
771 }
772
773 #[test]
774 fn test_apply_no_auth() {
775 let mut config = Config::default();
776 config.security.auth.enabled = true;
777
778 let args = Args {
779 no_auth: true,
780 ..Args::default()
781 };
782
783 config.apply_args(&args);
784 assert!(!config.security.auth.enabled);
785 }
786
787 #[test]
788 fn test_apply_require_auth() {
789 let mut config = Config::default();
790 assert!(!config.security.auth.enabled); config.apply_args(&Args {
793 require_auth: true,
794 ..Args::default()
795 });
796 assert!(config.security.auth.enabled);
797 }
798
799 #[test]
800 fn test_no_auth_overrides_require_auth() {
801 let mut config = Config::default();
802
803 config.apply_args(&Args {
805 require_auth: true,
806 no_auth: true,
807 ..Args::default()
808 });
809 assert!(!config.security.auth.enabled);
810 }
811
812 #[test]
813 fn test_to_server_config() {
814 let config = Config::default();
815 let server_config = config.to_server_config().unwrap();
816
817 assert_eq!(server_config.host, "127.0.0.1");
818 assert_eq!(server_config.port, 3000);
819 }
820
821 #[test]
822 fn test_apply_args_capabilities_and_preset() {
823 let mut config = Config::default();
824 config.apply_args(&Args {
825 capabilities: vec!["exec".to_string(), "session.read".to_string()],
826 preset: Some("operator".to_string()),
827 ..Args::default()
828 });
829 assert_eq!(
830 config.security.auth.capabilities,
831 vec!["exec", "session.read"]
832 );
833 assert_eq!(config.security.auth.preset, Some("operator".to_string()));
834 }
835
836 #[test]
837 fn test_scope_implies_auth_on() {
838 let mut by_preset = Config::default();
841 by_preset.apply_args(&Args {
842 preset: Some("file-read".to_string()),
843 ..Args::default()
844 });
845 assert!(by_preset.security.auth.enabled);
846
847 let mut by_caps = Config::default();
848 by_caps.apply_args(&Args {
849 capabilities: vec!["session.read".to_string()],
850 ..Args::default()
851 });
852 assert!(by_caps.security.auth.enabled);
853 }
854
855 #[test]
866 fn a_scope_named_on_the_command_line_replaces_the_files_scope() {
867 let mut config = Config::default();
868 config.security.auth.preset = Some("operator".to_string());
869
870 config.apply_args(&Args {
871 capabilities: vec!["fs.read".to_string()],
872 ..Args::default()
873 });
874
875 assert_eq!(
876 config.security.auth.preset, None,
877 "the file's preset must not survive a scope named on the command line"
878 );
879 assert_eq!(config.security.auth.capabilities, vec!["fs.read"]);
880 assert_eq!(
881 resolve_capabilities(
882 config.security.auth.preset.as_deref(),
883 &config.security.auth.capabilities,
884 )
885 .expect("valid")
886 .expect("a scope was named")
887 .iter()
888 .collect::<Vec<_>>(),
889 vec!["fs.read"],
890 "and the resolved set is what was asked for, with no exec left in it"
891 );
892 }
893
894 #[test]
898 fn a_preset_named_on_the_command_line_replaces_the_files_capabilities() {
899 let mut config = Config::default();
900 config.security.auth.capabilities = vec!["exec".to_string()];
901
902 config.apply_args(&Args {
903 preset: Some("file-read".to_string()),
904 ..Args::default()
905 });
906
907 assert!(
908 config.security.auth.capabilities.is_empty(),
909 "the file's capability list must not survive a preset named on the command line"
910 );
911 assert_eq!(config.security.auth.preset, Some("file-read".to_string()));
912 }
913
914 #[test]
918 fn a_preset_and_capabilities_on_one_command_line_still_union() {
919 let mut config = Config::default();
920 config.apply_args(&Args {
921 preset: Some("file-read".to_string()),
922 capabilities: vec!["session.read".to_string()],
923 ..Args::default()
924 });
925
926 let resolved = resolve_capabilities(
927 config.security.auth.preset.as_deref(),
928 &config.security.auth.capabilities,
929 )
930 .expect("valid")
931 .expect("a scope was named");
932 assert!(resolved.satisfies("fs.read"), "from the preset");
933 assert!(resolved.satisfies("session.read"), "from the list");
934 }
935
936 #[test]
937 fn test_no_auth_overrides_scope_implied_auth() {
938 let mut config = Config::default();
940 config.apply_args(&Args {
941 preset: Some("file-read".to_string()),
942 no_auth: true,
943 ..Args::default()
944 });
945 assert!(!config.security.auth.enabled);
946 }
947
948 #[test]
949 fn test_config_from_json_with_capabilities_and_preset() {
950 let json = r#"{
953 "security": {
954 "auth": {
955 "enabled": true,
956 "api_keys": ["scoped"],
957 "preset": "file-read",
958 "capabilities": ["exec"]
959 }
960 }
961 }"#;
962 let mut file = NamedTempFile::new().unwrap();
963 file.write_all(json.as_bytes()).unwrap();
964
965 let config = Config::from_file(file.path()).unwrap();
966 assert_eq!(config.security.auth.preset, Some("file-read".to_string()));
967 assert_eq!(config.security.auth.capabilities, vec!["exec"]);
968
969 let server_config = config.to_server_config().unwrap();
970 let caps = server_config
971 .security
972 .capabilities
973 .expect("capabilities scoped from file");
974 assert!(caps.satisfies("fs.read")); assert!(caps.satisfies("exec")); assert!(!caps.satisfies("session.manage"));
977 }
978
979 #[test]
980 fn test_resolve_capabilities_none_by_default() {
981 assert!(resolve_capabilities(None, &[]).unwrap().is_none());
983 }
984
985 #[test]
986 fn test_resolve_capabilities_preset_plus_extra() {
987 let set = resolve_capabilities(Some("file-read"), &["exec".to_string()])
989 .unwrap()
990 .unwrap();
991 assert!(set.satisfies("fs.read"));
992 assert!(set.satisfies("exec"));
993 assert!(!set.satisfies("session.manage"));
994 }
995
996 #[test]
997 fn test_resolve_capabilities_invalid_preset_errors() {
998 let err = resolve_capabilities(Some("superuser"), &[]);
999 assert!(matches!(err, Err(ConfigError::InvalidPreset(_))));
1000 }
1001
1002 #[test]
1003 fn test_to_server_config_scopes_capabilities() {
1004 let mut config = Config::default();
1005 config.security.auth.enabled = true;
1006 config.security.auth.api_keys = vec!["scoped".to_string()];
1007 config.security.auth.preset = Some("file-read".to_string());
1008
1009 let server_config = config.to_server_config().unwrap();
1010 let caps = server_config
1011 .security
1012 .capabilities
1013 .expect("capabilities scoped");
1014 assert!(caps.satisfies("fs.read"));
1015 assert!(!caps.satisfies("exec"));
1016 }
1017
1018 #[test]
1019 fn test_to_server_config_invalid_preset_errors() {
1020 let mut config = Config::default();
1021 config.security.auth.preset = Some("root".to_string());
1022 assert!(matches!(
1023 config.to_server_config(),
1024 Err(ConfigError::InvalidPreset(_))
1025 ));
1026 }
1027
1028 #[test]
1029 fn the_read_only_refusal_names_its_replacement() {
1030 let err = ConfigError::InvalidPreset("read-only".to_string());
1031 let message = err.to_string();
1032 assert!(
1033 message.contains("file-read"),
1034 "must point at the replacement: {message}"
1035 );
1036 assert!(
1037 message.contains("session.read"),
1038 "must offer the exact escape: {message}"
1039 );
1040 assert!(
1043 message.contains("security.auth.preset"),
1044 "must name the config key, not only the flags: {message}"
1045 );
1046 }
1047
1048 #[test]
1049 fn an_unknown_preset_lists_the_valid_ones() {
1050 let err = ConfigError::InvalidPreset("nonsense".to_string());
1051 let message = err.to_string();
1052 for name in ["operator", "file-write", "file-read", "full-control"] {
1053 assert!(message.contains(name), "must list {name}: {message}");
1054 }
1055 assert!(
1056 !message.contains("read-only"),
1057 "must not advertise a removed preset: {message}"
1058 );
1059 }
1060
1061 #[test]
1062 fn test_invalid_host() {
1063 let mut config = Config::default();
1064 config.server.host = "not-an-ip".to_string();
1065
1066 let result = config.to_server_config();
1067 assert!(result.is_err());
1068 }
1069
1070 #[test]
1071 fn test_config_serialization() {
1072 let config = Config::default();
1073 let json = serde_json::to_string_pretty(&config).unwrap();
1074 assert!(json.contains("\"host\""));
1075 assert!(json.contains("\"port\""));
1076 }
1077
1078 fn tunnel_args() -> Args {
1079 Args {
1080 tunnel: true,
1081 ..Default::default()
1082 }
1083 }
1084
1085 #[test]
1086 fn test_public_exposure_refuses_no_auth() {
1087 let mut config = Config::default();
1088 let args = Args {
1089 no_auth: true,
1090 ..tunnel_args()
1091 };
1092 let err = config.harden_for_public_exposure(&args).unwrap_err();
1093 assert!(matches!(err, ConfigError::RemoteWithoutAuth));
1094 assert!(err.to_string().contains("unauthenticated shell"));
1095 }
1096
1097 #[test]
1098 fn test_public_exposure_enables_auth_and_generates_a_key() {
1099 let mut config = Config::default();
1100 assert!(!config.security.auth.enabled);
1101
1102 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1103
1104 assert!(config.security.auth.enabled);
1105 let key = exposure.generated_key.expect("a key must be generated");
1106 assert!(key.starts_with("st_"));
1107 assert_eq!(config.security.auth.api_keys, vec![key]);
1108 }
1109
1110 #[test]
1111 fn test_public_exposure_keeps_a_supplied_key() {
1112 let mut config = Config::default();
1113 config.security.auth.api_keys.push("my-key".to_string());
1114
1115 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1116
1117 assert!(exposure.generated_key.is_none());
1118 assert_eq!(config.security.auth.api_keys, vec!["my-key".to_string()]);
1119 }
1120
1121 #[test]
1122 fn test_public_exposure_no_longer_warns_about_an_unscoped_token_because_it_scopes_it() {
1123 let mut config = Config::default();
1124 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1125 assert!(
1126 !exposure.warnings.iter().any(|w| w.contains("full control")),
1127 "{:?}",
1128 exposure.warnings
1129 );
1130 }
1131
1132 #[test]
1133 fn test_public_exposure_does_not_warn_about_a_scoped_token() {
1134 let mut config = Config::default();
1135 config.security.auth.preset = Some("operator".to_string());
1136 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1137 assert!(
1138 !exposure.warnings.iter().any(|w| w.contains("full control")),
1139 "{:?}",
1140 exposure.warnings
1141 );
1142 }
1143
1144 #[test]
1145 fn test_public_exposure_warns_about_disabled_rate_limit() {
1146 let mut config = Config::default();
1147 config.security.rate_limit.enabled = false;
1148
1149 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1150
1151 assert!(exposure
1152 .warnings
1153 .iter()
1154 .any(|w| w.contains("rate limiting")));
1155 }
1156
1157 #[test]
1158 fn test_public_exposure_is_quiet_on_a_scoped_loopback_setup() {
1159 let mut config = Config::default();
1160 config.security.auth.preset = Some("operator".to_string());
1161 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1162 assert!(exposure.warnings.is_empty(), "{:?}", exposure.warnings);
1163 }
1164
1165 #[test]
1166 fn exposure_scopes_the_issued_token_instead_of_warning_about_it() {
1167 let mut config = Config::default();
1168 assert!(config.security.auth.preset.is_none());
1169
1170 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1171
1172 assert_eq!(config.security.auth.preset.as_deref(), Some("operator"));
1174 assert!(
1175 !exposure.warnings.iter().any(|w| w.contains("full control")),
1176 "the warning must be gone, not merely reworded: {:?}",
1177 exposure.warnings
1178 );
1179 }
1180
1181 #[test]
1182 fn the_exposed_token_is_not_a_wildcard() {
1183 let mut config = Config::default();
1187 config.harden_for_public_exposure(&tunnel_args()).unwrap();
1188
1189 let set = resolve_capabilities(
1190 config.security.auth.preset.as_deref(),
1191 &config.security.auth.capabilities,
1192 )
1193 .unwrap()
1194 .expect("an exposed token must have an explicit set");
1195 assert!(!set.is_wildcard());
1196 assert!(set.satisfies("exec"));
1197 assert!(set.satisfies("fs.write"));
1198 }
1199
1200 #[test]
1201 fn an_explicit_scope_is_left_alone() {
1202 let mut config = Config::default();
1203 config.security.auth.preset = Some("file-read".to_string());
1204
1205 config.harden_for_public_exposure(&tunnel_args()).unwrap();
1206
1207 assert_eq!(config.security.auth.preset.as_deref(), Some("file-read"));
1208 }
1209
1210 #[test]
1211 fn explicit_capabilities_are_left_alone_too() {
1212 let mut config = Config::default();
1213 config.security.auth.capabilities = vec!["exec".to_string()];
1214
1215 config.harden_for_public_exposure(&tunnel_args()).unwrap();
1216
1217 assert!(config.security.auth.preset.is_none());
1218 assert_eq!(config.security.auth.capabilities, vec!["exec".to_string()]);
1219 }
1220
1221 #[test]
1222 fn a_non_loopback_bind_no_longer_warns_because_it_now_decides_the_posture() {
1223 let mut config = Config::default();
1224 config.server.host = "0.0.0.0".to_string();
1225
1226 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1227
1228 assert!(
1229 !exposure.warnings.iter().any(|w| w.contains("binding")),
1230 "posture covers this now: {:?}",
1231 exposure.warnings
1232 );
1233 }
1234
1235 #[test]
1236 fn a_disabled_rate_limit_still_warns() {
1237 let mut config = Config::default();
1240 config.security.rate_limit.enabled = false;
1241
1242 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1243
1244 assert!(exposure
1245 .warnings
1246 .iter()
1247 .any(|w| w.contains("rate limiting")));
1248 }
1249
1250 #[test]
1251 fn a_loopback_server_answers_only_to_local_names() {
1252 let config = Config::default();
1253 let hosts = config
1254 .allowed_hosts(&Args::default(), false)
1255 .expect("a loopback server gets a list");
1256
1257 assert!(hosts.contains(&"localhost".to_string()));
1258 assert!(hosts.contains(&"127.0.0.1".to_string()));
1259 }
1260
1261 #[test]
1262 fn a_published_server_is_not_host_checked() {
1263 let config = Config::default();
1266 assert!(config.allowed_hosts(&Args::default(), true).is_none());
1267 }
1268
1269 #[test]
1270 fn a_non_loopback_bind_is_not_host_checked() {
1271 let mut config = Config::default();
1272 config.server.host = "0.0.0.0".to_string();
1273 assert!(config.allowed_hosts(&Args::default(), false).is_none());
1274 }
1275
1276 #[test]
1277 fn extra_allowed_hosts_join_the_defaults() {
1278 let config = Config::default();
1279 let args = Args {
1280 allow_hosts: vec!["myapp.internal".to_string()],
1281 ..Default::default()
1282 };
1283 let hosts = config.allowed_hosts(&args, false).unwrap();
1284
1285 assert!(hosts.contains(&"myapp.internal".to_string()));
1286 assert!(hosts.contains(&"localhost".to_string()));
1287 }
1288
1289 #[test]
1290 fn test_transport_defaults_to_local_only() {
1291 let config = Config::default();
1292 assert_eq!(config.transport.mode, TransportMode::None);
1293 assert!(config.tunnel_provider().unwrap().is_none());
1294 }
1295
1296 #[test]
1297 fn test_transport_mode_from_config_file() {
1298 let json = r#"{"transport":{"mode":"cloudflared"}}"#;
1299 let config: Config = serde_json::from_str(json).unwrap();
1300 assert_eq!(config.transport.mode, TransportMode::Cloudflared);
1301 let provider = config.tunnel_provider().unwrap().expect("a provider");
1302 assert_eq!(provider.name(), "cloudflared");
1303 }
1304
1305 #[test]
1306 fn test_transport_command_from_config_file() {
1307 let json = r#"{"transport":{"mode":"command","command":"ngrok http 3000"}}"#;
1308 let config: Config = serde_json::from_str(json).unwrap();
1309 let provider = config.tunnel_provider().unwrap().expect("a provider");
1310 assert_eq!(provider.name(), "tunnel-command");
1311 }
1312
1313 #[test]
1314 fn test_transport_command_mode_requires_a_command() {
1315 let json = r#"{"transport":{"mode":"command"}}"#;
1316 let config: Config = serde_json::from_str(json).unwrap();
1317 let err = config.tunnel_provider().unwrap_err();
1318 assert!(matches!(err, ConfigError::MissingTunnelCommand));
1319 assert!(err.to_string().contains("transport.command"));
1320 }
1321
1322 #[test]
1323 fn test_cli_tunnel_overrides_config_file() {
1324 let mut config: Config =
1325 serde_json::from_str(r#"{"transport":{"mode":"command","command":"old"}}"#).unwrap();
1326 config.apply_args(&Args {
1327 tunnel: true,
1328 ..Default::default()
1329 });
1330 assert_eq!(config.transport.mode, TransportMode::Cloudflared);
1331 }
1332
1333 #[test]
1334 fn test_cli_tunnel_command_overrides_config_file() {
1335 let mut config: Config =
1336 serde_json::from_str(r#"{"transport":{"mode":"cloudflared"}}"#).unwrap();
1337 config.apply_args(&Args {
1338 tunnel_command: Some("bore local 3000 --to bore.pub".to_string()),
1339 ..Default::default()
1340 });
1341 assert_eq!(config.transport.mode, TransportMode::Command);
1342 assert_eq!(
1343 config.transport.command.as_deref(),
1344 Some("bore local 3000 --to bore.pub")
1345 );
1346 }
1347
1348 #[test]
1349 fn test_config_file_transport_survives_unrelated_args() {
1350 let mut config: Config =
1351 serde_json::from_str(r#"{"transport":{"mode":"cloudflared"}}"#).unwrap();
1352 config.apply_args(&Args::default());
1353 assert_eq!(config.transport.mode, TransportMode::Cloudflared);
1354 }
1355
1356 #[test]
1357 fn loopback_bind_without_a_public_path_is_local() {
1358 let config = Config::default();
1359 assert_eq!(config.server.host, "127.0.0.1");
1360 assert_eq!(config.posture(false, false), Posture::Local);
1361 }
1362
1363 #[test]
1364 fn a_tunnel_or_a_relay_makes_it_exposed() {
1365 let config = Config::default();
1366 assert_eq!(config.posture(true, false), Posture::Exposed);
1367 assert_eq!(config.posture(false, true), Posture::Exposed);
1368 }
1369
1370 #[test]
1371 fn a_non_loopback_bind_is_exposed_on_its_own() {
1372 let mut config = Config::default();
1374 config.server.host = "0.0.0.0".to_string();
1375 assert_eq!(config.posture(false, false), Posture::Exposed);
1376
1377 config.server.host = "192.168.1.10".to_string();
1378 assert_eq!(config.posture(false, false), Posture::Exposed);
1379
1380 config.server.host = "::".to_string();
1381 assert_eq!(config.posture(false, false), Posture::Exposed);
1382 }
1383
1384 #[test]
1385 fn ipv6_loopback_is_local() {
1386 let mut config = Config::default();
1387 config.server.host = "::1".to_string();
1388 assert_eq!(config.posture(false, false), Posture::Local);
1389 }
1390
1391 #[test]
1392 fn an_unparseable_host_is_exposed_rather_than_local() {
1393 let mut config = Config::default();
1397 config.server.host = "not-an-ip".to_string();
1398 assert_eq!(config.posture(false, false), Posture::Exposed);
1399 }
1400}