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) {
192 self.server.host = args.host.to_string();
193 self.server.port = args.port;
194
195 if let Some(ref key) = args.api_key {
196 self.security.auth.enabled = true;
197 if !self.security.auth.api_keys.contains(key) {
198 self.security.auth.api_keys.push(key.clone());
199 }
200 }
201
202 if args.require_auth {
205 self.security.auth.enabled = true;
206 }
207
208 if !args.capabilities.is_empty() {
213 self.security.auth.capabilities = args.capabilities.clone();
214 self.security.auth.enabled = true;
215 }
216 if let Some(ref preset) = args.preset {
217 self.security.auth.preset = Some(preset.clone());
218 self.security.auth.enabled = true;
219 }
220
221 if args.no_auth {
222 self.security.auth.enabled = false;
223 }
224
225 if let Some(ref command) = args.tunnel_command {
228 self.transport.mode = TransportMode::Command;
229 self.transport.command = Some(command.clone());
230 } else if args.tunnel {
231 self.transport.mode = TransportMode::Cloudflared;
232 }
233
234 if args.no_rate_limit {
235 self.security.rate_limit.enabled = false;
236 }
237
238 if args.cors_allow_any {
239 self.security.cors.allow_any = true;
240 }
241
242 if let Some(ref level) = args.log_level {
243 self.logging.level = level.clone();
244 }
245 }
246
247 pub fn load(args: &Args) -> Result<Self, ConfigError> {
251 let mut config = Config::default();
253
254 if let Some(ref path) = args.config {
256 config = Config::from_file(path)?;
257 }
258
259 config.apply_env();
261
262 config.apply_args(args);
264
265 Ok(config)
266 }
267
268 pub fn allowed_hosts(&self, args: &Args, published: bool) -> Option<Vec<String>> {
277 let host: IpAddr = self.server.host.parse().ok()?;
278 if published || !host.is_loopback() {
279 return None;
280 }
281
282 let mut hosts = vec![
283 "localhost".to_string(),
284 "127.0.0.1".to_string(),
285 "::1".to_string(),
286 ];
287 hosts.extend(args.allow_hosts.iter().cloned());
288 Some(hosts)
289 }
290
291 pub fn tunnel_provider(&self) -> Result<Option<Box<dyn TunnelProvider>>, ConfigError> {
293 match self.transport.mode {
294 TransportMode::None => Ok(None),
295 TransportMode::Cloudflared => Ok(Some(Box::new(Cloudflared))),
296 TransportMode::Command => {
297 let command = self
298 .transport
299 .command
300 .as_deref()
301 .filter(|c| !c.trim().is_empty())
302 .ok_or(ConfigError::MissingTunnelCommand)?;
303 Ok(Some(Box::new(CustomCommand::new(command))))
304 }
305 }
306 }
307
308 pub fn harden_for_public_exposure(
321 &mut self,
322 args: &Args,
323 ) -> Result<PublicExposure, ConfigError> {
324 if args.no_auth {
325 return Err(ConfigError::RemoteWithoutAuth);
326 }
327
328 self.security.auth.enabled = true;
329
330 let generated_key = if self.security.auth.api_keys.is_empty() {
331 let key = crate::security::generate_api_key();
332 self.security.auth.api_keys.push(key.clone());
333 Some(key)
334 } else {
335 None
336 };
337
338 let mut warnings = Vec::new();
339 if self.security.auth.preset.is_none() && self.security.auth.capabilities.is_empty() {
340 warnings.push(
341 "the issued token has full control over this machine and is reachable from the internet; scope it with --preset operator or --capabilities"
342 .to_string(),
343 );
344 }
345 if !self.security.rate_limit.enabled {
346 warnings.push("rate limiting is disabled on a publicly reachable server".to_string());
347 }
348 if let Ok(ip) = self.server.host.parse::<IpAddr>() {
349 if !ip.is_loopback() {
350 warnings.push(format!(
351 "binding {} exposes the server directly in addition to the tunnel; 127.0.0.1 is enough when a tunnel provides reachability",
352 ip
353 ));
354 }
355 }
356
357 Ok(PublicExposure {
358 generated_key,
359 warnings,
360 })
361 }
362
363 pub fn to_server_config(&self) -> Result<ServerConfig, ConfigError> {
365 let host: IpAddr = self
366 .server
367 .host
368 .parse()
369 .map_err(|_| ConfigError::InvalidHost(self.server.host.clone()))?;
370
371 let mut security = if self.security.auth.enabled {
372 SecurityConfig::secure()
373 } else {
374 SecurityConfig::development()
375 };
376
377 security.auth = AuthConfig {
379 enabled: self.security.auth.enabled,
380 ..AuthConfig::default()
381 };
382
383 security.rate_limit = RateLimitConfig {
385 enabled: self.security.rate_limit.enabled,
386 max_requests: self.security.rate_limit.requests_per_window,
387 window: std::time::Duration::from_secs(self.security.rate_limit.window_secs),
388 max_tracked_ips: 10000,
389 };
390
391 security.cors = CorsConfig {
393 allow_any: self.security.cors.allow_any,
394 };
395
396 if let Some(capabilities) = resolve_capabilities(
398 self.security.auth.preset.as_deref(),
399 &self.security.auth.capabilities,
400 )? {
401 security = security.with_capabilities(capabilities);
402 }
403
404 for key in &self.security.auth.api_keys {
406 security = security.with_api_key(key);
407 }
408
409 let mut server_config = ServerConfig::new(host.to_string(), self.server.port);
410 server_config = server_config.with_security(security);
411
412 if !self.server.graceful_shutdown {
413 server_config = server_config.without_graceful_shutdown();
414 }
415
416 Ok(server_config)
417 }
418
419 pub fn log_filter(&self) -> &str {
421 &self.logging.level
422 }
423}
424
425fn resolve_capabilities(
431 preset: Option<&str>,
432 capabilities: &[String],
433) -> Result<Option<CapabilitySet>, ConfigError> {
434 if preset.is_none() && capabilities.is_empty() {
435 return Ok(None); }
437
438 let mut set = match preset {
439 Some(name) => crate::security::preset(name)
440 .ok_or_else(|| ConfigError::InvalidPreset(name.to_string()))?,
441 None => CapabilitySet::new(),
442 };
443 for capability in capabilities {
444 set.insert(capability.clone());
445 }
446 Ok(Some(set))
447}
448
449#[derive(Debug, Clone, Default)]
451pub struct PublicExposure {
452 pub generated_key: Option<String>,
454 pub warnings: Vec<String>,
456}
457
458#[derive(Debug)]
460pub enum ConfigError {
461 Io(std::io::Error),
463 Json(serde_json::Error),
465 InvalidHost(String),
467 InvalidPreset(String),
469 RemoteWithoutAuth,
471 MissingTunnelCommand,
473}
474
475impl std::fmt::Display for ConfigError {
476 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
477 match self {
478 Self::Io(e) => write!(f, "failed to read config file: {}", e),
479 Self::Json(e) => write!(f, "failed to parse config file: {}", e),
480 Self::InvalidHost(host) => write!(f, "invalid host address: {}", host),
481 Self::InvalidPreset(name) => write!(
482 f,
483 "unknown role preset: '{}' (expected operator, read-only, or full-control)",
484 name
485 ),
486 Self::MissingTunnelCommand => write!(
487 f,
488 "transport.mode is \"command\" but transport.command is not set (or use --tunnel-command)"
489 ),
490 Self::RemoteWithoutAuth => write!(
491 f,
492 "--no-auth cannot be combined with a public tunnel: that would expose an unauthenticated shell to the internet. Drop --no-auth (a key is generated for you), or drop the tunnel"
493 ),
494 }
495 }
496}
497
498impl std::error::Error for ConfigError {}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use std::io::Write;
504 use tempfile::NamedTempFile;
505
506 #[test]
507 fn test_default_config() {
508 let config = Config::default();
509 assert_eq!(config.server.host, "127.0.0.1");
510 assert_eq!(config.server.port, 3000);
511 assert!(!config.security.auth.enabled);
512 assert!(config.security.rate_limit.enabled);
513 }
514
515 #[test]
516 fn test_config_from_json() {
517 let json = r#"{
518 "server": {
519 "host": "0.0.0.0",
520 "port": 8080
521 },
522 "security": {
523 "auth": {
524 "enabled": true,
525 "api_keys": ["key1", "key2"]
526 }
527 }
528 }"#;
529
530 let mut file = NamedTempFile::new().unwrap();
531 file.write_all(json.as_bytes()).unwrap();
532
533 let config = Config::from_file(file.path()).unwrap();
534 assert_eq!(config.server.host, "0.0.0.0");
535 assert_eq!(config.server.port, 8080);
536 assert!(config.security.auth.enabled);
537 assert_eq!(config.security.auth.api_keys.len(), 2);
538 }
539
540 #[test]
541 fn test_config_partial_json() {
542 let json = r#"{
543 "server": {
544 "port": 9000
545 }
546 }"#;
547
548 let mut file = NamedTempFile::new().unwrap();
549 file.write_all(json.as_bytes()).unwrap();
550
551 let config = Config::from_file(file.path()).unwrap();
552 assert_eq!(config.server.host, "127.0.0.1"); assert_eq!(config.server.port, 9000);
554 }
555
556 #[test]
557 fn test_apply_args() {
558 let mut config = Config::default();
559 let args = Args {
560 host: "192.168.1.1".parse().unwrap(),
561 port: 5000,
562 api_key: Some("test-key".to_string()),
563 no_rate_limit: true,
564 ..Args::default()
565 };
566
567 config.apply_args(&args);
568
569 assert_eq!(config.server.host, "192.168.1.1");
570 assert_eq!(config.server.port, 5000);
571 assert!(config.security.auth.enabled);
572 assert!(config
573 .security
574 .auth
575 .api_keys
576 .contains(&"test-key".to_string()));
577 assert!(!config.security.rate_limit.enabled);
578 }
579
580 #[test]
581 fn test_apply_no_auth() {
582 let mut config = Config::default();
583 config.security.auth.enabled = true;
584
585 let args = Args {
586 no_auth: true,
587 ..Args::default()
588 };
589
590 config.apply_args(&args);
591 assert!(!config.security.auth.enabled);
592 }
593
594 #[test]
595 fn test_apply_require_auth() {
596 let mut config = Config::default();
597 assert!(!config.security.auth.enabled); config.apply_args(&Args {
600 require_auth: true,
601 ..Args::default()
602 });
603 assert!(config.security.auth.enabled);
604 }
605
606 #[test]
607 fn test_no_auth_overrides_require_auth() {
608 let mut config = Config::default();
609
610 config.apply_args(&Args {
612 require_auth: true,
613 no_auth: true,
614 ..Args::default()
615 });
616 assert!(!config.security.auth.enabled);
617 }
618
619 #[test]
620 fn test_to_server_config() {
621 let config = Config::default();
622 let server_config = config.to_server_config().unwrap();
623
624 assert_eq!(server_config.host, "127.0.0.1");
625 assert_eq!(server_config.port, 3000);
626 }
627
628 #[test]
629 fn test_apply_args_capabilities_and_preset() {
630 let mut config = Config::default();
631 config.apply_args(&Args {
632 capabilities: vec!["exec".to_string(), "session.read".to_string()],
633 preset: Some("operator".to_string()),
634 ..Args::default()
635 });
636 assert_eq!(
637 config.security.auth.capabilities,
638 vec!["exec", "session.read"]
639 );
640 assert_eq!(config.security.auth.preset, Some("operator".to_string()));
641 }
642
643 #[test]
644 fn test_scope_implies_auth_on() {
645 let mut by_preset = Config::default();
648 by_preset.apply_args(&Args {
649 preset: Some("read-only".to_string()),
650 ..Args::default()
651 });
652 assert!(by_preset.security.auth.enabled);
653
654 let mut by_caps = Config::default();
655 by_caps.apply_args(&Args {
656 capabilities: vec!["session.read".to_string()],
657 ..Args::default()
658 });
659 assert!(by_caps.security.auth.enabled);
660 }
661
662 #[test]
663 fn test_no_auth_overrides_scope_implied_auth() {
664 let mut config = Config::default();
666 config.apply_args(&Args {
667 preset: Some("read-only".to_string()),
668 no_auth: true,
669 ..Args::default()
670 });
671 assert!(!config.security.auth.enabled);
672 }
673
674 #[test]
675 fn test_config_from_json_with_capabilities_and_preset() {
676 let json = r#"{
679 "security": {
680 "auth": {
681 "enabled": true,
682 "api_keys": ["scoped"],
683 "preset": "read-only",
684 "capabilities": ["exec"]
685 }
686 }
687 }"#;
688 let mut file = NamedTempFile::new().unwrap();
689 file.write_all(json.as_bytes()).unwrap();
690
691 let config = Config::from_file(file.path()).unwrap();
692 assert_eq!(config.security.auth.preset, Some("read-only".to_string()));
693 assert_eq!(config.security.auth.capabilities, vec!["exec"]);
694
695 let server_config = config.to_server_config().unwrap();
696 let caps = server_config
697 .security
698 .capabilities
699 .expect("capabilities scoped from file");
700 assert!(caps.satisfies("session.read")); assert!(caps.satisfies("exec")); assert!(!caps.satisfies("session.manage"));
703 }
704
705 #[test]
706 fn test_resolve_capabilities_none_by_default() {
707 assert!(resolve_capabilities(None, &[]).unwrap().is_none());
709 }
710
711 #[test]
712 fn test_resolve_capabilities_preset_plus_extra() {
713 let set = resolve_capabilities(Some("read-only"), &["exec".to_string()])
715 .unwrap()
716 .unwrap();
717 assert!(set.satisfies("session.read"));
718 assert!(set.satisfies("exec"));
719 assert!(!set.satisfies("session.manage"));
720 }
721
722 #[test]
723 fn test_resolve_capabilities_invalid_preset_errors() {
724 let err = resolve_capabilities(Some("superuser"), &[]);
725 assert!(matches!(err, Err(ConfigError::InvalidPreset(_))));
726 }
727
728 #[test]
729 fn test_to_server_config_scopes_capabilities() {
730 let mut config = Config::default();
731 config.security.auth.enabled = true;
732 config.security.auth.api_keys = vec!["scoped".to_string()];
733 config.security.auth.preset = Some("read-only".to_string());
734
735 let server_config = config.to_server_config().unwrap();
736 let caps = server_config
737 .security
738 .capabilities
739 .expect("capabilities scoped");
740 assert!(caps.satisfies("session.read"));
741 assert!(!caps.satisfies("exec"));
742 }
743
744 #[test]
745 fn test_to_server_config_invalid_preset_errors() {
746 let mut config = Config::default();
747 config.security.auth.preset = Some("root".to_string());
748 assert!(matches!(
749 config.to_server_config(),
750 Err(ConfigError::InvalidPreset(_))
751 ));
752 }
753
754 #[test]
755 fn test_invalid_host() {
756 let mut config = Config::default();
757 config.server.host = "not-an-ip".to_string();
758
759 let result = config.to_server_config();
760 assert!(result.is_err());
761 }
762
763 #[test]
764 fn test_config_serialization() {
765 let config = Config::default();
766 let json = serde_json::to_string_pretty(&config).unwrap();
767 assert!(json.contains("\"host\""));
768 assert!(json.contains("\"port\""));
769 }
770
771 fn tunnel_args() -> Args {
772 Args {
773 tunnel: true,
774 ..Default::default()
775 }
776 }
777
778 #[test]
779 fn test_public_exposure_refuses_no_auth() {
780 let mut config = Config::default();
781 let args = Args {
782 no_auth: true,
783 ..tunnel_args()
784 };
785 let err = config.harden_for_public_exposure(&args).unwrap_err();
786 assert!(matches!(err, ConfigError::RemoteWithoutAuth));
787 assert!(err.to_string().contains("unauthenticated shell"));
788 }
789
790 #[test]
791 fn test_public_exposure_enables_auth_and_generates_a_key() {
792 let mut config = Config::default();
793 assert!(!config.security.auth.enabled);
794
795 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
796
797 assert!(config.security.auth.enabled);
798 let key = exposure.generated_key.expect("a key must be generated");
799 assert!(key.starts_with("st_"));
800 assert_eq!(config.security.auth.api_keys, vec![key]);
801 }
802
803 #[test]
804 fn test_public_exposure_keeps_a_supplied_key() {
805 let mut config = Config::default();
806 config.security.auth.api_keys.push("my-key".to_string());
807
808 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
809
810 assert!(exposure.generated_key.is_none());
811 assert_eq!(config.security.auth.api_keys, vec!["my-key".to_string()]);
812 }
813
814 #[test]
815 fn test_public_exposure_warns_about_an_unscoped_token() {
816 let mut config = Config::default();
817 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
818 assert!(
819 exposure.warnings.iter().any(|w| w.contains("full control")),
820 "{:?}",
821 exposure.warnings
822 );
823 }
824
825 #[test]
826 fn test_public_exposure_does_not_warn_about_a_scoped_token() {
827 let mut config = Config::default();
828 config.security.auth.preset = Some("operator".to_string());
829 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
830 assert!(
831 !exposure.warnings.iter().any(|w| w.contains("full control")),
832 "{:?}",
833 exposure.warnings
834 );
835 }
836
837 #[test]
838 fn test_public_exposure_warns_about_disabled_rate_limit_and_public_bind() {
839 let mut config = Config::default();
840 config.security.rate_limit.enabled = false;
841 config.server.host = "0.0.0.0".to_string();
842
843 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
844
845 assert!(exposure
846 .warnings
847 .iter()
848 .any(|w| w.contains("rate limiting")));
849 assert!(exposure.warnings.iter().any(|w| w.contains("0.0.0.0")));
850 }
851
852 #[test]
853 fn test_public_exposure_is_quiet_on_a_scoped_loopback_setup() {
854 let mut config = Config::default();
855 config.security.auth.preset = Some("operator".to_string());
856 let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
857 assert!(exposure.warnings.is_empty(), "{:?}", exposure.warnings);
858 }
859
860 #[test]
861 fn a_loopback_server_answers_only_to_local_names() {
862 let config = Config::default();
863 let hosts = config
864 .allowed_hosts(&Args::default(), false)
865 .expect("a loopback server gets a list");
866
867 assert!(hosts.contains(&"localhost".to_string()));
868 assert!(hosts.contains(&"127.0.0.1".to_string()));
869 }
870
871 #[test]
872 fn a_published_server_is_not_host_checked() {
873 let config = Config::default();
876 assert!(config.allowed_hosts(&Args::default(), true).is_none());
877 }
878
879 #[test]
880 fn a_non_loopback_bind_is_not_host_checked() {
881 let mut config = Config::default();
882 config.server.host = "0.0.0.0".to_string();
883 assert!(config.allowed_hosts(&Args::default(), false).is_none());
884 }
885
886 #[test]
887 fn extra_allowed_hosts_join_the_defaults() {
888 let config = Config::default();
889 let args = Args {
890 allow_hosts: vec!["myapp.internal".to_string()],
891 ..Default::default()
892 };
893 let hosts = config.allowed_hosts(&args, false).unwrap();
894
895 assert!(hosts.contains(&"myapp.internal".to_string()));
896 assert!(hosts.contains(&"localhost".to_string()));
897 }
898
899 #[test]
900 fn test_transport_defaults_to_local_only() {
901 let config = Config::default();
902 assert_eq!(config.transport.mode, TransportMode::None);
903 assert!(config.tunnel_provider().unwrap().is_none());
904 }
905
906 #[test]
907 fn test_transport_mode_from_config_file() {
908 let json = r#"{"transport":{"mode":"cloudflared"}}"#;
909 let config: Config = serde_json::from_str(json).unwrap();
910 assert_eq!(config.transport.mode, TransportMode::Cloudflared);
911 let provider = config.tunnel_provider().unwrap().expect("a provider");
912 assert_eq!(provider.name(), "cloudflared");
913 }
914
915 #[test]
916 fn test_transport_command_from_config_file() {
917 let json = r#"{"transport":{"mode":"command","command":"ngrok http 3000"}}"#;
918 let config: Config = serde_json::from_str(json).unwrap();
919 let provider = config.tunnel_provider().unwrap().expect("a provider");
920 assert_eq!(provider.name(), "tunnel-command");
921 }
922
923 #[test]
924 fn test_transport_command_mode_requires_a_command() {
925 let json = r#"{"transport":{"mode":"command"}}"#;
926 let config: Config = serde_json::from_str(json).unwrap();
927 let err = config.tunnel_provider().unwrap_err();
928 assert!(matches!(err, ConfigError::MissingTunnelCommand));
929 assert!(err.to_string().contains("transport.command"));
930 }
931
932 #[test]
933 fn test_cli_tunnel_overrides_config_file() {
934 let mut config: Config =
935 serde_json::from_str(r#"{"transport":{"mode":"command","command":"old"}}"#).unwrap();
936 config.apply_args(&Args {
937 tunnel: true,
938 ..Default::default()
939 });
940 assert_eq!(config.transport.mode, TransportMode::Cloudflared);
941 }
942
943 #[test]
944 fn test_cli_tunnel_command_overrides_config_file() {
945 let mut config: Config =
946 serde_json::from_str(r#"{"transport":{"mode":"cloudflared"}}"#).unwrap();
947 config.apply_args(&Args {
948 tunnel_command: Some("bore local 3000 --to bore.pub".to_string()),
949 ..Default::default()
950 });
951 assert_eq!(config.transport.mode, TransportMode::Command);
952 assert_eq!(
953 config.transport.command.as_deref(),
954 Some("bore local 3000 --to bore.pub")
955 );
956 }
957
958 #[test]
959 fn test_config_file_transport_survives_unrelated_args() {
960 let mut config: Config =
961 serde_json::from_str(r#"{"transport":{"mode":"cloudflared"}}"#).unwrap();
962 config.apply_args(&Args::default());
963 assert_eq!(config.transport.mode, TransportMode::Cloudflared);
964 }
965}