Skip to main content

shell_tunnel/
config.rs

1//! Configuration management for shell-tunnel.
2//!
3//! Configuration is loaded with the following priority (highest to lowest):
4//! 1. Command-line arguments
5//! 2. Environment variables
6//! 3. Configuration file (JSON)
7//! 4. Default values
8
9use 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/// Application configuration.
20#[derive(Debug, Clone, Default, Serialize, Deserialize)]
21#[serde(default)]
22pub struct Config {
23    /// Server configuration.
24    pub server: ServerSection,
25    /// Security configuration.
26    pub security: SecuritySection,
27    /// How the server is made reachable.
28    pub transport: TransportSection,
29    /// Logging configuration.
30    pub logging: LoggingSection,
31}
32
33/// How the server is published to the outside world.
34///
35/// A single value rather than a set of flags: two reachability paths would each
36/// allocate a different public URL for one server, so the configuration is not
37/// allowed to express that state at all.
38#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "kebab-case")]
40pub enum TransportMode {
41    /// Bind locally only (default).
42    #[default]
43    None,
44    /// Run a Cloudflare quick tunnel.
45    Cloudflared,
46    /// Run the tunnel command in [`TransportSection::command`].
47    Command,
48}
49
50/// Reachability configuration section.
51#[derive(Debug, Clone, Default, Serialize, Deserialize)]
52#[serde(default)]
53pub struct TransportSection {
54    /// Which reachability path to use.
55    pub mode: TransportMode,
56    /// Tunnel command to run when `mode` is `command`.
57    pub command: Option<String>,
58}
59
60/// Server configuration section.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(default)]
63pub struct ServerSection {
64    /// Host address to bind to.
65    pub host: String,
66    /// Port to listen on.
67    pub port: u16,
68    /// Enable graceful shutdown.
69    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/// Security configuration section.
83#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84#[serde(default)]
85pub struct SecuritySection {
86    /// Authentication settings.
87    pub auth: AuthSection,
88    /// Rate limiting settings.
89    pub rate_limit: RateLimitSection,
90    /// CORS settings.
91    pub cors: CorsSection,
92}
93
94/// CORS configuration section.
95#[derive(Debug, Clone, Default, Serialize, Deserialize)]
96#[serde(default)]
97pub struct CorsSection {
98    /// Allow any origin (permissive CORS). Off by default; enable only for
99    /// trusted browser-based UIs.
100    pub allow_any: bool,
101}
102
103/// Authentication configuration.
104#[derive(Debug, Clone, Default, Serialize, Deserialize)]
105#[serde(default)]
106pub struct AuthSection {
107    /// Enable authentication.
108    pub enabled: bool,
109    /// API keys.
110    pub api_keys: Vec<String>,
111    /// Capability strings scoping the keys (empty = full-control).
112    pub capabilities: Vec<String>,
113    /// Role preset scoping the keys (operator/read-only/full-control).
114    pub preset: Option<String>,
115}
116
117/// Rate limiting configuration.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119#[serde(default)]
120pub struct RateLimitSection {
121    /// Enable rate limiting.
122    pub enabled: bool,
123    /// Requests per window.
124    pub requests_per_window: u32,
125    /// Window size in seconds.
126    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/// Logging configuration section.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(default)]
142pub struct LoggingSection {
143    /// Log level (error, warn, info, debug, trace).
144    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    /// Load configuration from a JSON file.
157    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    /// Apply environment variable overrides.
163    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    /// Apply CLI argument overrides.
191    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        // Enable auth on request (a key is auto-generated at startup if none is set).
203        // Applied before `no_auth` so an explicit `--no-auth` still wins.
204        if args.require_auth {
205            self.security.auth.enabled = true;
206        }
207
208        // Token scoping (fine-grained capabilities / preset). Specifying a scope
209        // implies auth-on — otherwise the scope would be silently ignored and the
210        // server would start open, the opposite of what `--preset read-only` asks
211        // for. Applied before `no_auth` so an explicit `--no-auth` still wins.
212        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        // CLI reachability flags override the file; the parser has already
226        // rejected asking for two at once.
227        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    /// Load configuration with full priority chain.
248    ///
249    /// Priority: CLI args > env vars > config file > defaults
250    pub fn load(args: &Args) -> Result<Self, ConfigError> {
251        // Start with defaults
252        let mut config = Config::default();
253
254        // Load from config file if specified
255        if let Some(ref path) = args.config {
256            config = Config::from_file(path)?;
257        }
258
259        // Apply environment variable overrides
260        config.apply_env();
261
262        // Apply CLI argument overrides (highest priority)
263        config.apply_args(args);
264
265        Ok(config)
266    }
267
268    /// Build the tunnel provider this configuration asks for, if any.
269    pub fn tunnel_provider(&self) -> Result<Option<Box<dyn TunnelProvider>>, ConfigError> {
270        match self.transport.mode {
271            TransportMode::None => Ok(None),
272            TransportMode::Cloudflared => Ok(Some(Box::new(Cloudflared))),
273            TransportMode::Command => {
274                let command = self
275                    .transport
276                    .command
277                    .as_deref()
278                    .filter(|c| !c.trim().is_empty())
279                    .ok_or(ConfigError::MissingTunnelCommand)?;
280                Ok(Some(Box::new(CustomCommand::new(command))))
281            }
282        }
283    }
284
285    /// Harden the configuration for a publicly reachable deployment.
286    ///
287    /// Exposing the server through a tunnel turns every weak default into an
288    /// internet-facing one, so this is enforced rather than advised:
289    /// authentication is switched on, and a key is generated when none was
290    /// supplied (the caller reports it — an unusable server would be worse).
291    /// `--no-auth` is refused outright instead of being silently overridden.
292    ///
293    /// The remaining risks are real but legitimate choices, so they are warned
294    /// about rather than blocked: a full-control token on a public URL, rate
295    /// limiting turned off, and binding a non-loopback address in addition to
296    /// the tunnel.
297    pub fn harden_for_public_exposure(
298        &mut self,
299        args: &Args,
300    ) -> Result<PublicExposure, ConfigError> {
301        if args.no_auth {
302            return Err(ConfigError::RemoteWithoutAuth);
303        }
304
305        self.security.auth.enabled = true;
306
307        let generated_key = if self.security.auth.api_keys.is_empty() {
308            let key = crate::security::generate_api_key();
309            self.security.auth.api_keys.push(key.clone());
310            Some(key)
311        } else {
312            None
313        };
314
315        let mut warnings = Vec::new();
316        if self.security.auth.preset.is_none() && self.security.auth.capabilities.is_empty() {
317            warnings.push(
318                "the issued token has full control over this machine and is reachable from the internet; scope it with --preset operator or --capabilities"
319                    .to_string(),
320            );
321        }
322        if !self.security.rate_limit.enabled {
323            warnings.push("rate limiting is disabled on a publicly reachable server".to_string());
324        }
325        if let Ok(ip) = self.server.host.parse::<IpAddr>() {
326            if !ip.is_loopback() {
327                warnings.push(format!(
328                    "binding {} exposes the server directly in addition to the tunnel; 127.0.0.1 is enough when a tunnel provides reachability",
329                    ip
330                ));
331            }
332        }
333
334        Ok(PublicExposure {
335            generated_key,
336            warnings,
337        })
338    }
339
340    /// Convert to ServerConfig for the API server.
341    pub fn to_server_config(&self) -> Result<ServerConfig, ConfigError> {
342        let host: IpAddr = self
343            .server
344            .host
345            .parse()
346            .map_err(|_| ConfigError::InvalidHost(self.server.host.clone()))?;
347
348        let mut security = if self.security.auth.enabled {
349            SecurityConfig::secure()
350        } else {
351            SecurityConfig::development()
352        };
353
354        // Apply auth settings
355        security.auth = AuthConfig {
356            enabled: self.security.auth.enabled,
357            ..AuthConfig::default()
358        };
359
360        // Apply rate limit settings
361        security.rate_limit = RateLimitConfig {
362            enabled: self.security.rate_limit.enabled,
363            max_requests: self.security.rate_limit.requests_per_window,
364            window: std::time::Duration::from_secs(self.security.rate_limit.window_secs),
365            max_tracked_ips: 10000,
366        };
367
368        // Apply CORS settings (restrictive by default)
369        security.cors = CorsConfig {
370            allow_any: self.security.cors.allow_any,
371        };
372
373        // Resolve fine-grained token scoping (preset + capabilities).
374        if let Some(capabilities) = resolve_capabilities(
375            self.security.auth.preset.as_deref(),
376            &self.security.auth.capabilities,
377        )? {
378            security = security.with_capabilities(capabilities);
379        }
380
381        // Add API keys
382        for key in &self.security.auth.api_keys {
383            security = security.with_api_key(key);
384        }
385
386        let mut server_config = ServerConfig::new(host.to_string(), self.server.port);
387        server_config = server_config.with_security(security);
388
389        if !self.server.graceful_shutdown {
390            server_config = server_config.without_graceful_shutdown();
391        }
392
393        Ok(server_config)
394    }
395
396    /// Get the log level filter string.
397    pub fn log_filter(&self) -> &str {
398        &self.logging.level
399    }
400}
401
402/// Resolve a `preset` name + explicit `capabilities` list into a capability set.
403///
404/// Returns `Ok(None)` when neither is given (full-control default). The preset
405/// (if any) forms the base set and the explicit capabilities are unioned on top.
406/// An unknown preset name is an error.
407fn resolve_capabilities(
408    preset: Option<&str>,
409    capabilities: &[String],
410) -> Result<Option<CapabilitySet>, ConfigError> {
411    if preset.is_none() && capabilities.is_empty() {
412        return Ok(None); // Full-control (legacy-compatible) default.
413    }
414
415    let mut set = match preset {
416        Some(name) => crate::security::preset(name)
417            .ok_or_else(|| ConfigError::InvalidPreset(name.to_string()))?,
418        None => CapabilitySet::new(),
419    };
420    for capability in capabilities {
421        set.insert(capability.clone());
422    }
423    Ok(Some(set))
424}
425
426/// Outcome of hardening a configuration for public exposure.
427#[derive(Debug, Clone, Default)]
428pub struct PublicExposure {
429    /// Key generated because none was supplied — the only copy the user gets.
430    pub generated_key: Option<String>,
431    /// Risks that remain legitimate choices, reported rather than blocked.
432    pub warnings: Vec<String>,
433}
434
435/// Configuration errors.
436#[derive(Debug)]
437pub enum ConfigError {
438    /// IO error reading config file.
439    Io(std::io::Error),
440    /// JSON parsing error.
441    Json(serde_json::Error),
442    /// Invalid host address.
443    InvalidHost(String),
444    /// Unknown role preset name.
445    InvalidPreset(String),
446    /// A public reachability path was requested together with `--no-auth`.
447    RemoteWithoutAuth,
448    /// `transport.mode = "command"` without a command to run.
449    MissingTunnelCommand,
450}
451
452impl std::fmt::Display for ConfigError {
453    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
454        match self {
455            Self::Io(e) => write!(f, "failed to read config file: {}", e),
456            Self::Json(e) => write!(f, "failed to parse config file: {}", e),
457            Self::InvalidHost(host) => write!(f, "invalid host address: {}", host),
458            Self::InvalidPreset(name) => write!(
459                f,
460                "unknown role preset: '{}' (expected operator, read-only, or full-control)",
461                name
462            ),
463            Self::MissingTunnelCommand => write!(
464                f,
465                "transport.mode is \"command\" but transport.command is not set (or use --tunnel-command)"
466            ),
467            Self::RemoteWithoutAuth => write!(
468                f,
469                "--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"
470            ),
471        }
472    }
473}
474
475impl std::error::Error for ConfigError {}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use std::io::Write;
481    use tempfile::NamedTempFile;
482
483    #[test]
484    fn test_default_config() {
485        let config = Config::default();
486        assert_eq!(config.server.host, "127.0.0.1");
487        assert_eq!(config.server.port, 3000);
488        assert!(!config.security.auth.enabled);
489        assert!(config.security.rate_limit.enabled);
490    }
491
492    #[test]
493    fn test_config_from_json() {
494        let json = r#"{
495            "server": {
496                "host": "0.0.0.0",
497                "port": 8080
498            },
499            "security": {
500                "auth": {
501                    "enabled": true,
502                    "api_keys": ["key1", "key2"]
503                }
504            }
505        }"#;
506
507        let mut file = NamedTempFile::new().unwrap();
508        file.write_all(json.as_bytes()).unwrap();
509
510        let config = Config::from_file(file.path()).unwrap();
511        assert_eq!(config.server.host, "0.0.0.0");
512        assert_eq!(config.server.port, 8080);
513        assert!(config.security.auth.enabled);
514        assert_eq!(config.security.auth.api_keys.len(), 2);
515    }
516
517    #[test]
518    fn test_config_partial_json() {
519        let json = r#"{
520            "server": {
521                "port": 9000
522            }
523        }"#;
524
525        let mut file = NamedTempFile::new().unwrap();
526        file.write_all(json.as_bytes()).unwrap();
527
528        let config = Config::from_file(file.path()).unwrap();
529        assert_eq!(config.server.host, "127.0.0.1"); // Default
530        assert_eq!(config.server.port, 9000);
531    }
532
533    #[test]
534    fn test_apply_args() {
535        let mut config = Config::default();
536        let args = Args {
537            host: "192.168.1.1".parse().unwrap(),
538            port: 5000,
539            api_key: Some("test-key".to_string()),
540            no_rate_limit: true,
541            ..Args::default()
542        };
543
544        config.apply_args(&args);
545
546        assert_eq!(config.server.host, "192.168.1.1");
547        assert_eq!(config.server.port, 5000);
548        assert!(config.security.auth.enabled);
549        assert!(config
550            .security
551            .auth
552            .api_keys
553            .contains(&"test-key".to_string()));
554        assert!(!config.security.rate_limit.enabled);
555    }
556
557    #[test]
558    fn test_apply_no_auth() {
559        let mut config = Config::default();
560        config.security.auth.enabled = true;
561
562        let args = Args {
563            no_auth: true,
564            ..Args::default()
565        };
566
567        config.apply_args(&args);
568        assert!(!config.security.auth.enabled);
569    }
570
571    #[test]
572    fn test_apply_require_auth() {
573        let mut config = Config::default();
574        assert!(!config.security.auth.enabled); // disabled by default
575
576        config.apply_args(&Args {
577            require_auth: true,
578            ..Args::default()
579        });
580        assert!(config.security.auth.enabled);
581    }
582
583    #[test]
584    fn test_no_auth_overrides_require_auth() {
585        let mut config = Config::default();
586
587        // Contradictory flags: explicit --no-auth wins.
588        config.apply_args(&Args {
589            require_auth: true,
590            no_auth: true,
591            ..Args::default()
592        });
593        assert!(!config.security.auth.enabled);
594    }
595
596    #[test]
597    fn test_to_server_config() {
598        let config = Config::default();
599        let server_config = config.to_server_config().unwrap();
600
601        assert_eq!(server_config.host, "127.0.0.1");
602        assert_eq!(server_config.port, 3000);
603    }
604
605    #[test]
606    fn test_apply_args_capabilities_and_preset() {
607        let mut config = Config::default();
608        config.apply_args(&Args {
609            capabilities: vec!["exec".to_string(), "session.read".to_string()],
610            preset: Some("operator".to_string()),
611            ..Args::default()
612        });
613        assert_eq!(
614            config.security.auth.capabilities,
615            vec!["exec", "session.read"]
616        );
617        assert_eq!(config.security.auth.preset, Some("operator".to_string()));
618    }
619
620    #[test]
621    fn test_scope_implies_auth_on() {
622        // Specifying a scope (preset or capabilities) with no --api-key/--require-auth
623        // still turns auth on, so the server does not start open with the scope ignored.
624        let mut by_preset = Config::default();
625        by_preset.apply_args(&Args {
626            preset: Some("read-only".to_string()),
627            ..Args::default()
628        });
629        assert!(by_preset.security.auth.enabled);
630
631        let mut by_caps = Config::default();
632        by_caps.apply_args(&Args {
633            capabilities: vec!["session.read".to_string()],
634            ..Args::default()
635        });
636        assert!(by_caps.security.auth.enabled);
637    }
638
639    #[test]
640    fn test_no_auth_overrides_scope_implied_auth() {
641        // Explicit --no-auth wins even when a scope is given.
642        let mut config = Config::default();
643        config.apply_args(&Args {
644            preset: Some("read-only".to_string()),
645            no_auth: true,
646            ..Args::default()
647        });
648        assert!(!config.security.auth.enabled);
649    }
650
651    #[test]
652    fn test_config_from_json_with_capabilities_and_preset() {
653        // The new AuthSection fields deserialize from a config file and flow
654        // through to a scoped SecurityConfig.
655        let json = r#"{
656            "security": {
657                "auth": {
658                    "enabled": true,
659                    "api_keys": ["scoped"],
660                    "preset": "read-only",
661                    "capabilities": ["exec"]
662                }
663            }
664        }"#;
665        let mut file = NamedTempFile::new().unwrap();
666        file.write_all(json.as_bytes()).unwrap();
667
668        let config = Config::from_file(file.path()).unwrap();
669        assert_eq!(config.security.auth.preset, Some("read-only".to_string()));
670        assert_eq!(config.security.auth.capabilities, vec!["exec"]);
671
672        let server_config = config.to_server_config().unwrap();
673        let caps = server_config
674            .security
675            .capabilities
676            .expect("capabilities scoped from file");
677        assert!(caps.satisfies("session.read")); // from read-only preset
678        assert!(caps.satisfies("exec")); // unioned explicit capability
679        assert!(!caps.satisfies("session.manage"));
680    }
681
682    #[test]
683    fn test_resolve_capabilities_none_by_default() {
684        // No preset, no capabilities -> full-control (None).
685        assert!(resolve_capabilities(None, &[]).unwrap().is_none());
686    }
687
688    #[test]
689    fn test_resolve_capabilities_preset_plus_extra() {
690        // read-only preset unioned with an explicit `exec`.
691        let set = resolve_capabilities(Some("read-only"), &["exec".to_string()])
692            .unwrap()
693            .unwrap();
694        assert!(set.satisfies("session.read"));
695        assert!(set.satisfies("exec"));
696        assert!(!set.satisfies("session.manage"));
697    }
698
699    #[test]
700    fn test_resolve_capabilities_invalid_preset_errors() {
701        let err = resolve_capabilities(Some("superuser"), &[]);
702        assert!(matches!(err, Err(ConfigError::InvalidPreset(_))));
703    }
704
705    #[test]
706    fn test_to_server_config_scopes_capabilities() {
707        let mut config = Config::default();
708        config.security.auth.enabled = true;
709        config.security.auth.api_keys = vec!["scoped".to_string()];
710        config.security.auth.preset = Some("read-only".to_string());
711
712        let server_config = config.to_server_config().unwrap();
713        let caps = server_config
714            .security
715            .capabilities
716            .expect("capabilities scoped");
717        assert!(caps.satisfies("session.read"));
718        assert!(!caps.satisfies("exec"));
719    }
720
721    #[test]
722    fn test_to_server_config_invalid_preset_errors() {
723        let mut config = Config::default();
724        config.security.auth.preset = Some("root".to_string());
725        assert!(matches!(
726            config.to_server_config(),
727            Err(ConfigError::InvalidPreset(_))
728        ));
729    }
730
731    #[test]
732    fn test_invalid_host() {
733        let mut config = Config::default();
734        config.server.host = "not-an-ip".to_string();
735
736        let result = config.to_server_config();
737        assert!(result.is_err());
738    }
739
740    #[test]
741    fn test_config_serialization() {
742        let config = Config::default();
743        let json = serde_json::to_string_pretty(&config).unwrap();
744        assert!(json.contains("\"host\""));
745        assert!(json.contains("\"port\""));
746    }
747
748    fn tunnel_args() -> Args {
749        Args {
750            tunnel: true,
751            ..Default::default()
752        }
753    }
754
755    #[test]
756    fn test_public_exposure_refuses_no_auth() {
757        let mut config = Config::default();
758        let args = Args {
759            no_auth: true,
760            ..tunnel_args()
761        };
762        let err = config.harden_for_public_exposure(&args).unwrap_err();
763        assert!(matches!(err, ConfigError::RemoteWithoutAuth));
764        assert!(err.to_string().contains("unauthenticated shell"));
765    }
766
767    #[test]
768    fn test_public_exposure_enables_auth_and_generates_a_key() {
769        let mut config = Config::default();
770        assert!(!config.security.auth.enabled);
771
772        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
773
774        assert!(config.security.auth.enabled);
775        let key = exposure.generated_key.expect("a key must be generated");
776        assert!(key.starts_with("st_"));
777        assert_eq!(config.security.auth.api_keys, vec![key]);
778    }
779
780    #[test]
781    fn test_public_exposure_keeps_a_supplied_key() {
782        let mut config = Config::default();
783        config.security.auth.api_keys.push("my-key".to_string());
784
785        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
786
787        assert!(exposure.generated_key.is_none());
788        assert_eq!(config.security.auth.api_keys, vec!["my-key".to_string()]);
789    }
790
791    #[test]
792    fn test_public_exposure_warns_about_an_unscoped_token() {
793        let mut config = Config::default();
794        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
795        assert!(
796            exposure.warnings.iter().any(|w| w.contains("full control")),
797            "{:?}",
798            exposure.warnings
799        );
800    }
801
802    #[test]
803    fn test_public_exposure_does_not_warn_about_a_scoped_token() {
804        let mut config = Config::default();
805        config.security.auth.preset = Some("operator".to_string());
806        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
807        assert!(
808            !exposure.warnings.iter().any(|w| w.contains("full control")),
809            "{:?}",
810            exposure.warnings
811        );
812    }
813
814    #[test]
815    fn test_public_exposure_warns_about_disabled_rate_limit_and_public_bind() {
816        let mut config = Config::default();
817        config.security.rate_limit.enabled = false;
818        config.server.host = "0.0.0.0".to_string();
819
820        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
821
822        assert!(exposure
823            .warnings
824            .iter()
825            .any(|w| w.contains("rate limiting")));
826        assert!(exposure.warnings.iter().any(|w| w.contains("0.0.0.0")));
827    }
828
829    #[test]
830    fn test_public_exposure_is_quiet_on_a_scoped_loopback_setup() {
831        let mut config = Config::default();
832        config.security.auth.preset = Some("operator".to_string());
833        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
834        assert!(exposure.warnings.is_empty(), "{:?}", exposure.warnings);
835    }
836
837    #[test]
838    fn test_transport_defaults_to_local_only() {
839        let config = Config::default();
840        assert_eq!(config.transport.mode, TransportMode::None);
841        assert!(config.tunnel_provider().unwrap().is_none());
842    }
843
844    #[test]
845    fn test_transport_mode_from_config_file() {
846        let json = r#"{"transport":{"mode":"cloudflared"}}"#;
847        let config: Config = serde_json::from_str(json).unwrap();
848        assert_eq!(config.transport.mode, TransportMode::Cloudflared);
849        let provider = config.tunnel_provider().unwrap().expect("a provider");
850        assert_eq!(provider.name(), "cloudflared");
851    }
852
853    #[test]
854    fn test_transport_command_from_config_file() {
855        let json = r#"{"transport":{"mode":"command","command":"ngrok http 3000"}}"#;
856        let config: Config = serde_json::from_str(json).unwrap();
857        let provider = config.tunnel_provider().unwrap().expect("a provider");
858        assert_eq!(provider.name(), "tunnel-command");
859    }
860
861    #[test]
862    fn test_transport_command_mode_requires_a_command() {
863        let json = r#"{"transport":{"mode":"command"}}"#;
864        let config: Config = serde_json::from_str(json).unwrap();
865        let err = config.tunnel_provider().unwrap_err();
866        assert!(matches!(err, ConfigError::MissingTunnelCommand));
867        assert!(err.to_string().contains("transport.command"));
868    }
869
870    #[test]
871    fn test_cli_tunnel_overrides_config_file() {
872        let mut config: Config =
873            serde_json::from_str(r#"{"transport":{"mode":"command","command":"old"}}"#).unwrap();
874        config.apply_args(&Args {
875            tunnel: true,
876            ..Default::default()
877        });
878        assert_eq!(config.transport.mode, TransportMode::Cloudflared);
879    }
880
881    #[test]
882    fn test_cli_tunnel_command_overrides_config_file() {
883        let mut config: Config =
884            serde_json::from_str(r#"{"transport":{"mode":"cloudflared"}}"#).unwrap();
885        config.apply_args(&Args {
886            tunnel_command: Some("bore local 3000 --to bore.pub".to_string()),
887            ..Default::default()
888        });
889        assert_eq!(config.transport.mode, TransportMode::Command);
890        assert_eq!(
891            config.transport.command.as_deref(),
892            Some("bore local 3000 --to bore.pub")
893        );
894    }
895
896    #[test]
897    fn test_config_file_transport_survives_unrelated_args() {
898        let mut config: Config =
899            serde_json::from_str(r#"{"transport":{"mode":"cloudflared"}}"#).unwrap();
900        config.apply_args(&Args::default());
901        assert_eq!(config.transport.mode, TransportMode::Cloudflared);
902    }
903}