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    /// Host names this server should answer to, or `None` to accept any.
269    ///
270    /// Only a loopback-bound server that is not published gets a list. That is
271    /// exactly where DNS rebinding applies: a browser resolves the attacker's
272    /// name to `127.0.0.1`, so the request is same-origin and CORS never sees
273    /// it, but the `Host` header still says whose name it was. A server reached
274    /// through a tunnel or relay is deliberately published under a name we may
275    /// not know, so checking would only refuse legitimate traffic.
276    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    /// Build the tunnel provider this configuration asks for, if any.
292    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    /// Harden the configuration for a publicly reachable deployment.
309    ///
310    /// Exposing the server through a tunnel turns every weak default into an
311    /// internet-facing one, so this is enforced rather than advised:
312    /// authentication is switched on, and a key is generated when none was
313    /// supplied (the caller reports it — an unusable server would be worse).
314    /// `--no-auth` is refused outright instead of being silently overridden.
315    ///
316    /// The remaining risks are real but legitimate choices, so they are warned
317    /// about rather than blocked: a full-control token on a public URL, rate
318    /// limiting turned off, and binding a non-loopback address in addition to
319    /// the tunnel.
320    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    /// Convert to ServerConfig for the API server.
364    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        // Apply auth settings
378        security.auth = AuthConfig {
379            enabled: self.security.auth.enabled,
380            ..AuthConfig::default()
381        };
382
383        // Apply rate limit settings
384        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        // Apply CORS settings (restrictive by default)
392        security.cors = CorsConfig {
393            allow_any: self.security.cors.allow_any,
394        };
395
396        // Resolve fine-grained token scoping (preset + capabilities).
397        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        // Add API keys
405        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    /// Get the log level filter string.
420    pub fn log_filter(&self) -> &str {
421        &self.logging.level
422    }
423}
424
425/// Resolve a `preset` name + explicit `capabilities` list into a capability set.
426///
427/// Returns `Ok(None)` when neither is given (full-control default). The preset
428/// (if any) forms the base set and the explicit capabilities are unioned on top.
429/// An unknown preset name is an error.
430fn 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); // Full-control (legacy-compatible) default.
436    }
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/// Outcome of hardening a configuration for public exposure.
450#[derive(Debug, Clone, Default)]
451pub struct PublicExposure {
452    /// Key generated because none was supplied — the only copy the user gets.
453    pub generated_key: Option<String>,
454    /// Risks that remain legitimate choices, reported rather than blocked.
455    pub warnings: Vec<String>,
456}
457
458/// Configuration errors.
459#[derive(Debug)]
460pub enum ConfigError {
461    /// IO error reading config file.
462    Io(std::io::Error),
463    /// JSON parsing error.
464    Json(serde_json::Error),
465    /// Invalid host address.
466    InvalidHost(String),
467    /// Unknown role preset name.
468    InvalidPreset(String),
469    /// A public reachability path was requested together with `--no-auth`.
470    RemoteWithoutAuth,
471    /// `transport.mode = "command"` without a command to run.
472    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"); // Default
553        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); // disabled by default
598
599        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        // Contradictory flags: explicit --no-auth wins.
611        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        // Specifying a scope (preset or capabilities) with no --api-key/--require-auth
646        // still turns auth on, so the server does not start open with the scope ignored.
647        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        // Explicit --no-auth wins even when a scope is given.
665        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        // The new AuthSection fields deserialize from a config file and flow
677        // through to a scoped SecurityConfig.
678        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")); // from read-only preset
701        assert!(caps.satisfies("exec")); // unioned explicit capability
702        assert!(!caps.satisfies("session.manage"));
703    }
704
705    #[test]
706    fn test_resolve_capabilities_none_by_default() {
707        // No preset, no capabilities -> full-control (None).
708        assert!(resolve_capabilities(None, &[]).unwrap().is_none());
709    }
710
711    #[test]
712    fn test_resolve_capabilities_preset_plus_extra() {
713        // read-only preset unioned with an explicit `exec`.
714        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        // Reached under a name we may not know; checking would only refuse
874        // legitimate traffic.
875        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}