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 = if self.security.auth.api_keys.is_empty() {
386 let key = crate::security::generate_api_key();
387 self.security.auth.api_keys.push(key.clone());
388 Some(key)
389 } else {
390 None
391 };
392
393 if self.security.auth.preset.is_none() && self.security.auth.capabilities.is_empty() {
405 self.security.auth.preset = Some("operator".to_string());
406 }
407
408 let mut warnings = Vec::new();
409 if !self.security.rate_limit.enabled {
413 warnings.push("rate limiting is disabled on a publicly reachable server".to_string());
414 }
415
416 Ok(PublicExposure {
417 generated_key,
418 warnings,
419 })
420 }
421
422 pub fn to_server_config(&self) -> Result<ServerConfig, ConfigError> {
424 let host: IpAddr = self
425 .server
426 .host
427 .parse()
428 .map_err(|_| ConfigError::InvalidHost(self.server.host.clone()))?;
429
430 let mut security = if self.security.auth.enabled {
431 SecurityConfig::secure()
432 } else {
433 SecurityConfig::development()
434 };
435
436 security.auth = AuthConfig {
438 enabled: self.security.auth.enabled,
439 ..AuthConfig::default()
440 };
441
442 security.rate_limit = RateLimitConfig {
444 enabled: self.security.rate_limit.enabled,
445 max_requests: self.security.rate_limit.requests_per_window,
446 window: std::time::Duration::from_secs(self.security.rate_limit.window_secs),
447 max_tracked_ips: 10000,
448 };
449
450 security.cors = CorsConfig {
452 allow_any: self.security.cors.allow_any,
453 };
454
455 if let Some(capabilities) = resolve_capabilities(
457 self.security.auth.preset.as_deref(),
458 &self.security.auth.capabilities,
459 )? {
460 security = security.with_capabilities(capabilities);
461 }
462
463 for key in &self.security.auth.api_keys {
465 security = security.with_api_key(key);
466 }
467
468 let mut server_config = ServerConfig::new(host.to_string(), self.server.port);
469 server_config = server_config.with_security(security);
470
471 if !self.server.graceful_shutdown {
472 server_config = server_config.without_graceful_shutdown();
473 }
474
475 Ok(server_config)
476 }
477
478 pub fn resolved_capabilities(&self) -> Result<Option<CapabilitySet>, ConfigError> {
486 resolve_capabilities(
487 self.security.auth.preset.as_deref(),
488 &self.security.auth.capabilities,
489 )
490 }
491
492 pub fn log_filter(&self) -> &str {
494 &self.logging.level
495 }
496}
497
498fn resolve_capabilities(
504 preset: Option<&str>,
505 capabilities: &[String],
506) -> Result<Option<CapabilitySet>, ConfigError> {
507 if preset.is_none() && capabilities.is_empty() {
508 return Ok(None); }
510
511 let mut set = match preset {
512 Some(name) => crate::security::preset(name)
513 .ok_or_else(|| ConfigError::InvalidPreset(name.to_string()))?,
514 None => CapabilitySet::new(),
515 };
516 for capability in capabilities {
517 set.insert(capability.clone());
518 }
519 Ok(Some(set))
520}
521
522#[derive(Debug, Clone, Copy, PartialEq, Eq)]
528pub enum Posture {
529 Local,
531 Exposed,
533}
534
535#[derive(Debug, Clone, Default)]
537pub struct PublicExposure {
538 pub generated_key: Option<String>,
540 pub warnings: Vec<String>,
542}
543
544#[derive(Debug)]
546pub enum ConfigError {
547 Io(std::io::Error),
549 Json(serde_json::Error),
551 InvalidHost(String),
553 InvalidPreset(String),
555 RemoteWithoutAuth,
557 MissingTunnelCommand,
559}
560
561impl std::fmt::Display for ConfigError {
562 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
563 match self {
564 Self::Io(e) => write!(f, "failed to read config file: {}", e),
565 Self::Json(e) => write!(f, "failed to parse config file: {}", e),
566 Self::InvalidHost(host) => write!(f, "invalid host address: {}", host),
567 Self::InvalidPreset(name) if name == "read-only" => {
568 write!(
569 f,
570 "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"
575 )
576 }
577 Self::InvalidPreset(name) => write!(
578 f,
579 "unknown role preset: '{}' (expected operator, file-write, file-read, or full-control)",
580 name
581 ),
582 Self::MissingTunnelCommand => write!(
583 f,
584 "transport.mode is \"command\" but transport.command is not set (or use --tunnel-command)"
585 ),
586 Self::RemoteWithoutAuth => write!(
587 f,
588 "--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"
589 ),
590 }
591 }
592}
593
594impl std::error::Error for ConfigError {}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599 use std::io::Write;
600 use tempfile::NamedTempFile;
601
602 #[test]
603 fn test_default_config() {
604 let config = Config::default();
605 assert_eq!(config.server.host, "127.0.0.1");
606 assert_eq!(config.server.port, 3000);
607 assert!(!config.security.auth.enabled);
608 assert!(config.security.rate_limit.enabled);
609 }
610
611 #[test]
612 fn test_config_from_json() {
613 let json = r#"{
614 "server": {
615 "host": "0.0.0.0",
616 "port": 8080
617 },
618 "security": {
619 "auth": {
620 "enabled": true,
621 "api_keys": ["key1", "key2"]
622 }
623 }
624 }"#;
625
626 let mut file = NamedTempFile::new().unwrap();
627 file.write_all(json.as_bytes()).unwrap();
628
629 let config = Config::from_file(file.path()).unwrap();
630 assert_eq!(config.server.host, "0.0.0.0");
631 assert_eq!(config.server.port, 8080);
632 assert!(config.security.auth.enabled);
633 assert_eq!(config.security.auth.api_keys.len(), 2);
634 }
635
636 #[test]
637 fn test_config_partial_json() {
638 let json = r#"{
639 "server": {
640 "port": 9000
641 }
642 }"#;
643
644 let mut file = NamedTempFile::new().unwrap();
645 file.write_all(json.as_bytes()).unwrap();
646
647 let config = Config::from_file(file.path()).unwrap();
648 assert_eq!(config.server.host, "127.0.0.1"); assert_eq!(config.server.port, 9000);
650 }
651
652 #[test]
653 fn test_apply_args() {
654 let mut config = Config::default();
655 let args = Args {
656 host: "192.168.1.1".parse().unwrap(),
657 host_explicit: true,
663 port: 5000,
664 port_explicit: true,
665 api_key: Some("test-key".to_string()),
666 no_rate_limit: true,
667 ..Args::default()
668 };
669
670 config.apply_args(&args);
671
672 assert_eq!(config.server.host, "192.168.1.1");
673 assert_eq!(config.server.port, 5000);
674 assert!(config.security.auth.enabled);
675 assert!(config
676 .security
677 .auth
678 .api_keys
679 .contains(&"test-key".to_string()));
680 assert!(!config.security.rate_limit.enabled);
681 }
682
683 #[test]
692 fn a_configured_host_and_port_survive_when_no_flag_names_them() {
693 let mut config = Config::default();
694 config.server.host = "0.0.0.0".to_string();
698 config.server.port = 8080;
699
700 let nothing_passed = Args::default();
701 assert!(
702 !nothing_passed.port_explicit && !nothing_passed.host_explicit,
703 "the premise: no flag was given"
704 );
705 config.apply_args(¬hing_passed);
706
707 assert_eq!(config.server.host, "0.0.0.0");
708 assert_eq!(config.server.port, 8080);
709 }
710
711 #[test]
714 fn a_named_host_and_port_beat_the_configured_ones() {
715 let mut config = Config::default();
716 config.server.host = "0.0.0.0".to_string();
717 config.server.port = 8080;
718
719 config.apply_args(&Args {
720 host: "10.0.0.5".parse().expect("addr"),
721 host_explicit: true,
722 port: 9999,
723 port_explicit: true,
724 ..Args::default()
725 });
726
727 assert_eq!(config.server.host, "10.0.0.5");
728 assert_eq!(config.server.port, 9999);
729 }
730
731 #[test]
740 fn a_configured_non_loopback_host_now_decides_the_posture() {
741 let mut config = Config::default();
742 config.server.host = "0.0.0.0".to_string();
743 config.apply_args(&Args::default());
744
745 assert_eq!(
746 config.posture(false, false),
747 Posture::Exposed,
748 "a bind address that now takes effect must also be seen by the posture"
749 );
750 let server = config.to_server_config().expect("valid config");
751 assert_eq!(
752 server.host, "0.0.0.0",
753 "the posture and the listener must read the same field"
754 );
755 }
756
757 #[test]
758 fn test_apply_no_auth() {
759 let mut config = Config::default();
760 config.security.auth.enabled = true;
761
762 let args = Args {
763 no_auth: true,
764 ..Args::default()
765 };
766
767 config.apply_args(&args);
768 assert!(!config.security.auth.enabled);
769 }
770
771 #[test]
772 fn test_apply_require_auth() {
773 let mut config = Config::default();
774 assert!(!config.security.auth.enabled); config.apply_args(&Args {
777 require_auth: true,
778 ..Args::default()
779 });
780 assert!(config.security.auth.enabled);
781 }
782
783 #[test]
784 fn test_no_auth_overrides_require_auth() {
785 let mut config = Config::default();
786
787 config.apply_args(&Args {
789 require_auth: true,
790 no_auth: true,
791 ..Args::default()
792 });
793 assert!(!config.security.auth.enabled);
794 }
795
796 #[test]
797 fn test_to_server_config() {
798 let config = Config::default();
799 let server_config = config.to_server_config().unwrap();
800
801 assert_eq!(server_config.host, "127.0.0.1");
802 assert_eq!(server_config.port, 3000);
803 }
804
805 #[test]
806 fn test_apply_args_capabilities_and_preset() {
807 let mut config = Config::default();
808 config.apply_args(&Args {
809 capabilities: vec!["exec".to_string(), "session.read".to_string()],
810 preset: Some("operator".to_string()),
811 ..Args::default()
812 });
813 assert_eq!(
814 config.security.auth.capabilities,
815 vec!["exec", "session.read"]
816 );
817 assert_eq!(config.security.auth.preset, Some("operator".to_string()));
818 }
819
820 #[test]
821 fn test_scope_implies_auth_on() {
822 let mut by_preset = Config::default();
825 by_preset.apply_args(&Args {
826 preset: Some("file-read".to_string()),
827 ..Args::default()
828 });
829 assert!(by_preset.security.auth.enabled);
830
831 let mut by_caps = Config::default();
832 by_caps.apply_args(&Args {
833 capabilities: vec!["session.read".to_string()],
834 ..Args::default()
835 });
836 assert!(by_caps.security.auth.enabled);
837 }
838
839 #[test]
850 fn a_scope_named_on_the_command_line_replaces_the_files_scope() {
851 let mut config = Config::default();
852 config.security.auth.preset = Some("operator".to_string());
853
854 config.apply_args(&Args {
855 capabilities: vec!["fs.read".to_string()],
856 ..Args::default()
857 });
858
859 assert_eq!(
860 config.security.auth.preset, None,
861 "the file's preset must not survive a scope named on the command line"
862 );
863 assert_eq!(config.security.auth.capabilities, vec!["fs.read"]);
864 assert_eq!(
865 resolve_capabilities(
866 config.security.auth.preset.as_deref(),
867 &config.security.auth.capabilities,
868 )
869 .expect("valid")
870 .expect("a scope was named")
871 .iter()
872 .collect::<Vec<_>>(),
873 vec!["fs.read"],
874 "and the resolved set is what was asked for, with no exec left in it"
875 );
876 }
877
878 #[test]
882 fn a_preset_named_on_the_command_line_replaces_the_files_capabilities() {
883 let mut config = Config::default();
884 config.security.auth.capabilities = vec!["exec".to_string()];
885
886 config.apply_args(&Args {
887 preset: Some("file-read".to_string()),
888 ..Args::default()
889 });
890
891 assert!(
892 config.security.auth.capabilities.is_empty(),
893 "the file's capability list must not survive a preset named on the command line"
894 );
895 assert_eq!(config.security.auth.preset, Some("file-read".to_string()));
896 }
897
898 #[test]
902 fn a_preset_and_capabilities_on_one_command_line_still_union() {
903 let mut config = Config::default();
904 config.apply_args(&Args {
905 preset: Some("file-read".to_string()),
906 capabilities: vec!["session.read".to_string()],
907 ..Args::default()
908 });
909
910 let resolved = resolve_capabilities(
911 config.security.auth.preset.as_deref(),
912 &config.security.auth.capabilities,
913 )
914 .expect("valid")
915 .expect("a scope was named");
916 assert!(resolved.satisfies("fs.read"), "from the preset");
917 assert!(resolved.satisfies("session.read"), "from the list");
918 }
919
920 #[test]
921 fn test_no_auth_overrides_scope_implied_auth() {
922 let mut config = Config::default();
924 config.apply_args(&Args {
925 preset: Some("file-read".to_string()),
926 no_auth: true,
927 ..Args::default()
928 });
929 assert!(!config.security.auth.enabled);
930 }
931
932 #[test]
933 fn test_config_from_json_with_capabilities_and_preset() {
934 let json = r#"{
937 "security": {
938 "auth": {
939 "enabled": true,
940 "api_keys": ["scoped"],
941 "preset": "file-read",
942 "capabilities": ["exec"]
943 }
944 }
945 }"#;
946 let mut file = NamedTempFile::new().unwrap();
947 file.write_all(json.as_bytes()).unwrap();
948
949 let config = Config::from_file(file.path()).unwrap();
950 assert_eq!(config.security.auth.preset, Some("file-read".to_string()));
951 assert_eq!(config.security.auth.capabilities, vec!["exec"]);
952
953 let server_config = config.to_server_config().unwrap();
954 let caps = server_config
955 .security
956 .capabilities
957 .expect("capabilities scoped from file");
958 assert!(caps.satisfies("fs.read")); assert!(caps.satisfies("exec")); assert!(!caps.satisfies("session.manage"));
961 }
962
963 #[test]
964 fn test_resolve_capabilities_none_by_default() {
965 assert!(resolve_capabilities(None, &[]).unwrap().is_none());
967 }
968
969 #[test]
970 fn test_resolve_capabilities_preset_plus_extra() {
971 let set = resolve_capabilities(Some("file-read"), &["exec".to_string()])
973 .unwrap()
974 .unwrap();
975 assert!(set.satisfies("fs.read"));
976 assert!(set.satisfies("exec"));
977 assert!(!set.satisfies("session.manage"));
978 }
979
980 #[test]
981 fn test_resolve_capabilities_invalid_preset_errors() {
982 let err = resolve_capabilities(Some("superuser"), &[]);
983 assert!(matches!(err, Err(ConfigError::InvalidPreset(_))));
984 }
985
986 #[test]
987 fn test_to_server_config_scopes_capabilities() {
988 let mut config = Config::default();
989 config.security.auth.enabled = true;
990 config.security.auth.api_keys = vec!["scoped".to_string()];
991 config.security.auth.preset = Some("file-read".to_string());
992
993 let server_config = config.to_server_config().unwrap();
994 let caps = server_config
995 .security
996 .capabilities
997 .expect("capabilities scoped");
998 assert!(caps.satisfies("fs.read"));
999 assert!(!caps.satisfies("exec"));
1000 }
1001
1002 #[test]
1003 fn test_to_server_config_invalid_preset_errors() {
1004 let mut config = Config::default();
1005 config.security.auth.preset = Some("root".to_string());
1006 assert!(matches!(
1007 config.to_server_config(),
1008 Err(ConfigError::InvalidPreset(_))
1009 ));
1010 }
1011
1012 #[test]
1013 fn the_read_only_refusal_names_its_replacement() {
1014 let err = ConfigError::InvalidPreset("read-only".to_string());
1015 let message = err.to_string();
1016 assert!(
1017 message.contains("file-read"),
1018 "must point at the replacement: {message}"
1019 );
1020 assert!(
1021 message.contains("session.read"),
1022 "must offer the exact escape: {message}"
1023 );
1024 assert!(
1027 message.contains("security.auth.preset"),
1028 "must name the config key, not only the flags: {message}"
1029 );
1030 }
1031
1032 #[test]
1033 fn an_unknown_preset_lists_the_valid_ones() {
1034 let err = ConfigError::InvalidPreset("nonsense".to_string());
1035 let message = err.to_string();
1036 for name in ["operator", "file-write", "file-read", "full-control"] {
1037 assert!(message.contains(name), "must list {name}: {message}");
1038 }
1039 assert!(
1040 !message.contains("read-only"),
1041 "must not advertise a removed preset: {message}"
1042 );
1043 }
1044
1045 #[test]
1046 fn test_invalid_host() {
1047 let mut config = Config::default();
1048 config.server.host = "not-an-ip".to_string();
1049
1050 let result = config.to_server_config();
1051 assert!(result.is_err());
1052 }
1053
1054 #[test]
1055 fn test_config_serialization() {
1056 let config = Config::default();
1057 let json = serde_json::to_string_pretty(&config).unwrap();
1058 assert!(json.contains("\"host\""));
1059 assert!(json.contains("\"port\""));
1060 }
1061
1062 fn tunnel_args() -> Args {
1063 Args {
1064 tunnel: true,
1065 ..Default::default()
1066 }
1067 }
1068
1069 #[test]
1070 fn test_public_exposure_refuses_no_auth() {
1071 let mut config = Config::default();
1072 let args = Args {
1073 no_auth: true,
1074 ..tunnel_args()
1075 };
1076 let err = config.harden_for_public_exposure(&args).unwrap_err();
1077 assert!(matches!(err, ConfigError::RemoteWithoutAuth));
1078 assert!(err.to_string().contains("unauthenticated shell"));
1079 }
1080
1081 #[test]
1082 fn test_public_exposure_enables_auth_and_generates_a_key() {
1083 let mut config = Config::default();
1084 assert!(!config.security.auth.enabled);
1085
1086 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1087
1088 assert!(config.security.auth.enabled);
1089 let key = exposure.generated_key.expect("a key must be generated");
1090 assert!(key.starts_with("st_"));
1091 assert_eq!(config.security.auth.api_keys, vec![key]);
1092 }
1093
1094 #[test]
1095 fn test_public_exposure_keeps_a_supplied_key() {
1096 let mut config = Config::default();
1097 config.security.auth.api_keys.push("my-key".to_string());
1098
1099 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1100
1101 assert!(exposure.generated_key.is_none());
1102 assert_eq!(config.security.auth.api_keys, vec!["my-key".to_string()]);
1103 }
1104
1105 #[test]
1106 fn test_public_exposure_no_longer_warns_about_an_unscoped_token_because_it_scopes_it() {
1107 let mut config = Config::default();
1108 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1109 assert!(
1110 !exposure.warnings.iter().any(|w| w.contains("full control")),
1111 "{:?}",
1112 exposure.warnings
1113 );
1114 }
1115
1116 #[test]
1117 fn test_public_exposure_does_not_warn_about_a_scoped_token() {
1118 let mut config = Config::default();
1119 config.security.auth.preset = Some("operator".to_string());
1120 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1121 assert!(
1122 !exposure.warnings.iter().any(|w| w.contains("full control")),
1123 "{:?}",
1124 exposure.warnings
1125 );
1126 }
1127
1128 #[test]
1129 fn test_public_exposure_warns_about_disabled_rate_limit() {
1130 let mut config = Config::default();
1131 config.security.rate_limit.enabled = false;
1132
1133 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1134
1135 assert!(exposure
1136 .warnings
1137 .iter()
1138 .any(|w| w.contains("rate limiting")));
1139 }
1140
1141 #[test]
1142 fn test_public_exposure_is_quiet_on_a_scoped_loopback_setup() {
1143 let mut config = Config::default();
1144 config.security.auth.preset = Some("operator".to_string());
1145 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1146 assert!(exposure.warnings.is_empty(), "{:?}", exposure.warnings);
1147 }
1148
1149 #[test]
1150 fn exposure_scopes_the_issued_token_instead_of_warning_about_it() {
1151 let mut config = Config::default();
1152 assert!(config.security.auth.preset.is_none());
1153
1154 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1155
1156 assert_eq!(config.security.auth.preset.as_deref(), Some("operator"));
1158 assert!(
1159 !exposure.warnings.iter().any(|w| w.contains("full control")),
1160 "the warning must be gone, not merely reworded: {:?}",
1161 exposure.warnings
1162 );
1163 }
1164
1165 #[test]
1166 fn the_exposed_token_is_not_a_wildcard() {
1167 let mut config = Config::default();
1171 config.harden_for_public_exposure(&tunnel_args()).unwrap();
1172
1173 let set = resolve_capabilities(
1174 config.security.auth.preset.as_deref(),
1175 &config.security.auth.capabilities,
1176 )
1177 .unwrap()
1178 .expect("an exposed token must have an explicit set");
1179 assert!(!set.is_wildcard());
1180 assert!(set.satisfies("exec"));
1181 assert!(set.satisfies("fs.write"));
1182 }
1183
1184 #[test]
1185 fn an_explicit_scope_is_left_alone() {
1186 let mut config = Config::default();
1187 config.security.auth.preset = Some("file-read".to_string());
1188
1189 config.harden_for_public_exposure(&tunnel_args()).unwrap();
1190
1191 assert_eq!(config.security.auth.preset.as_deref(), Some("file-read"));
1192 }
1193
1194 #[test]
1195 fn explicit_capabilities_are_left_alone_too() {
1196 let mut config = Config::default();
1197 config.security.auth.capabilities = vec!["exec".to_string()];
1198
1199 config.harden_for_public_exposure(&tunnel_args()).unwrap();
1200
1201 assert!(config.security.auth.preset.is_none());
1202 assert_eq!(config.security.auth.capabilities, vec!["exec".to_string()]);
1203 }
1204
1205 #[test]
1206 fn a_non_loopback_bind_no_longer_warns_because_it_now_decides_the_posture() {
1207 let mut config = Config::default();
1208 config.server.host = "0.0.0.0".to_string();
1209
1210 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1211
1212 assert!(
1213 !exposure.warnings.iter().any(|w| w.contains("binding")),
1214 "posture covers this now: {:?}",
1215 exposure.warnings
1216 );
1217 }
1218
1219 #[test]
1220 fn a_disabled_rate_limit_still_warns() {
1221 let mut config = Config::default();
1224 config.security.rate_limit.enabled = false;
1225
1226 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1227
1228 assert!(exposure
1229 .warnings
1230 .iter()
1231 .any(|w| w.contains("rate limiting")));
1232 }
1233
1234 #[test]
1235 fn a_loopback_server_answers_only_to_local_names() {
1236 let config = Config::default();
1237 let hosts = config
1238 .allowed_hosts(&Args::default(), false)
1239 .expect("a loopback server gets a list");
1240
1241 assert!(hosts.contains(&"localhost".to_string()));
1242 assert!(hosts.contains(&"127.0.0.1".to_string()));
1243 }
1244
1245 #[test]
1246 fn a_published_server_is_not_host_checked() {
1247 let config = Config::default();
1250 assert!(config.allowed_hosts(&Args::default(), true).is_none());
1251 }
1252
1253 #[test]
1254 fn a_non_loopback_bind_is_not_host_checked() {
1255 let mut config = Config::default();
1256 config.server.host = "0.0.0.0".to_string();
1257 assert!(config.allowed_hosts(&Args::default(), false).is_none());
1258 }
1259
1260 #[test]
1261 fn extra_allowed_hosts_join_the_defaults() {
1262 let config = Config::default();
1263 let args = Args {
1264 allow_hosts: vec!["myapp.internal".to_string()],
1265 ..Default::default()
1266 };
1267 let hosts = config.allowed_hosts(&args, false).unwrap();
1268
1269 assert!(hosts.contains(&"myapp.internal".to_string()));
1270 assert!(hosts.contains(&"localhost".to_string()));
1271 }
1272
1273 #[test]
1274 fn test_transport_defaults_to_local_only() {
1275 let config = Config::default();
1276 assert_eq!(config.transport.mode, TransportMode::None);
1277 assert!(config.tunnel_provider().unwrap().is_none());
1278 }
1279
1280 #[test]
1281 fn test_transport_mode_from_config_file() {
1282 let json = r#"{"transport":{"mode":"cloudflared"}}"#;
1283 let config: Config = serde_json::from_str(json).unwrap();
1284 assert_eq!(config.transport.mode, TransportMode::Cloudflared);
1285 let provider = config.tunnel_provider().unwrap().expect("a provider");
1286 assert_eq!(provider.name(), "cloudflared");
1287 }
1288
1289 #[test]
1290 fn test_transport_command_from_config_file() {
1291 let json = r#"{"transport":{"mode":"command","command":"ngrok http 3000"}}"#;
1292 let config: Config = serde_json::from_str(json).unwrap();
1293 let provider = config.tunnel_provider().unwrap().expect("a provider");
1294 assert_eq!(provider.name(), "tunnel-command");
1295 }
1296
1297 #[test]
1298 fn test_transport_command_mode_requires_a_command() {
1299 let json = r#"{"transport":{"mode":"command"}}"#;
1300 let config: Config = serde_json::from_str(json).unwrap();
1301 let err = config.tunnel_provider().unwrap_err();
1302 assert!(matches!(err, ConfigError::MissingTunnelCommand));
1303 assert!(err.to_string().contains("transport.command"));
1304 }
1305
1306 #[test]
1307 fn test_cli_tunnel_overrides_config_file() {
1308 let mut config: Config =
1309 serde_json::from_str(r#"{"transport":{"mode":"command","command":"old"}}"#).unwrap();
1310 config.apply_args(&Args {
1311 tunnel: true,
1312 ..Default::default()
1313 });
1314 assert_eq!(config.transport.mode, TransportMode::Cloudflared);
1315 }
1316
1317 #[test]
1318 fn test_cli_tunnel_command_overrides_config_file() {
1319 let mut config: Config =
1320 serde_json::from_str(r#"{"transport":{"mode":"cloudflared"}}"#).unwrap();
1321 config.apply_args(&Args {
1322 tunnel_command: Some("bore local 3000 --to bore.pub".to_string()),
1323 ..Default::default()
1324 });
1325 assert_eq!(config.transport.mode, TransportMode::Command);
1326 assert_eq!(
1327 config.transport.command.as_deref(),
1328 Some("bore local 3000 --to bore.pub")
1329 );
1330 }
1331
1332 #[test]
1333 fn test_config_file_transport_survives_unrelated_args() {
1334 let mut config: Config =
1335 serde_json::from_str(r#"{"transport":{"mode":"cloudflared"}}"#).unwrap();
1336 config.apply_args(&Args::default());
1337 assert_eq!(config.transport.mode, TransportMode::Cloudflared);
1338 }
1339
1340 #[test]
1341 fn loopback_bind_without_a_public_path_is_local() {
1342 let config = Config::default();
1343 assert_eq!(config.server.host, "127.0.0.1");
1344 assert_eq!(config.posture(false, false), Posture::Local);
1345 }
1346
1347 #[test]
1348 fn a_tunnel_or_a_relay_makes_it_exposed() {
1349 let config = Config::default();
1350 assert_eq!(config.posture(true, false), Posture::Exposed);
1351 assert_eq!(config.posture(false, true), Posture::Exposed);
1352 }
1353
1354 #[test]
1355 fn a_non_loopback_bind_is_exposed_on_its_own() {
1356 let mut config = Config::default();
1358 config.server.host = "0.0.0.0".to_string();
1359 assert_eq!(config.posture(false, false), Posture::Exposed);
1360
1361 config.server.host = "192.168.1.10".to_string();
1362 assert_eq!(config.posture(false, false), Posture::Exposed);
1363
1364 config.server.host = "::".to_string();
1365 assert_eq!(config.posture(false, false), Posture::Exposed);
1366 }
1367
1368 #[test]
1369 fn ipv6_loopback_is_local() {
1370 let mut config = Config::default();
1371 config.server.host = "::1".to_string();
1372 assert_eq!(config.posture(false, false), Posture::Local);
1373 }
1374
1375 #[test]
1376 fn an_unparseable_host_is_exposed_rather_than_local() {
1377 let mut config = Config::default();
1381 config.server.host = "not-an-ip".to_string();
1382 assert_eq!(config.posture(false, false), Posture::Exposed);
1383 }
1384}