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/file-write/file-read/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    ///
192    /// A flag that was passed replaces what the file or the environment said;
193    /// a flag that was not passed leaves them alone. That reading is only
194    /// possible for arguments that can tell "not passed" from "passed the
195    /// default value" — hence `host_explicit`/`port_explicit`, since `Args`
196    /// carries `127.0.0.1` and `3000` either way and an unconditional
197    /// assignment made a configured bind address unreachable.
198    ///
199    /// It is not a universal, and the exceptions are not accidents. The
200    /// boolean flags below (`--no-auth`, `--require-auth`, `--no-rate-limit`,
201    /// `--cors-allow-any`) are one-way: passing one sets it, omitting one
202    /// leaves the file's value, and there is no flag that turns rate limiting
203    /// back *on* from the command line. Documenting the rule as universal has
204    /// been tried three times here and was false each time.
205    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        // Enable auth on request (a key is auto-generated at startup if none is set).
221        // Applied before `no_auth` so an explicit `--no-auth` still wins.
222        if args.require_auth {
223            self.security.auth.enabled = true;
224        }
225
226        // Token scoping (fine-grained capabilities / preset). Specifying a scope
227        // implies auth-on — otherwise the scope would be silently ignored and the
228        // server would start open, the opposite of what `--preset file-read` asks
229        // for. Applied before `no_auth` so an explicit `--no-auth` still wins.
230        //
231        // Naming *either* one on the command line clears *both* of the file's
232        // scope settings before applying what was named. `resolve_capabilities`
233        // unions a preset with an explicit list, which is right within one
234        // source — `--preset operator --capabilities fs.read` on one line is
235        // plainly a request to add — but across sources it inverted the
236        // operator's intent: a file saying `"preset": "operator"` plus a
237        // command line saying `--capabilities fs.read` issued a token holding
238        // operator's whole set *and* `fs.read`, `exec` still among them, when
239        // the command line was narrowing. A scope input that cannot narrow is
240        // not a scope input, and this failed in the reassuring direction.
241        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        // CLI reachability flags override the file; the parser has already
259        // rejected asking for two at once.
260        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    /// Load configuration with full priority chain.
281    ///
282    /// Priority: CLI args > env vars > config file > defaults
283    pub fn load(args: &Args) -> Result<Self, ConfigError> {
284        // Start with defaults
285        let mut config = Config::default();
286
287        // Load from config file if specified
288        if let Some(ref path) = args.config {
289            config = Config::from_file(path)?;
290        }
291
292        // Apply environment variable overrides
293        config.apply_env();
294
295        // Apply CLI argument overrides (highest priority)
296        config.apply_args(args);
297
298        Ok(config)
299    }
300
301    /// Host names this server should answer to, or `None` to accept any.
302    ///
303    /// Only a loopback-bound server that is not published gets a list. That is
304    /// exactly where DNS rebinding applies: a browser resolves the attacker's
305    /// name to `127.0.0.1`, so the request is same-origin and CORS never sees
306    /// it, but the `Host` header still says whose name it was. A server reached
307    /// through a tunnel or relay is deliberately published under a name we may
308    /// not know, so checking would only refuse legitimate traffic.
309    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    /// Build the tunnel provider this configuration asks for, if any.
325    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    /// Determine how far this configuration is exposed.
342    ///
343    /// `tunnel_configured` is a single fact after the CLI (`--tunnel`/`--tunnel-command`)
344    /// and config file (`transport.mode`) are merged — this function does not need to know
345    /// which input path it came from. `relay_attached` indicates whether `--relay` was given.
346    ///
347    /// Bind address is judged by `!ip.is_loopback()` alone. This condition is the same one
348    /// this file already uses for warnings — no new rules are introduced.
349    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            // Parse failures are already rejected by `to_server_config` with `InvalidHost`,
356            // so this branch is not reached in practice. Even so, we answer Exposed: inability
357            // to judge is not evidence of safety.
358            _ => Posture::Exposed,
359        }
360    }
361
362    /// Harden the configuration for a publicly reachable deployment.
363    ///
364    /// Exposing the server through a tunnel turns every weak default into an
365    /// internet-facing one, so this is enforced rather than advised:
366    /// authentication is switched on, and a key is generated when none was
367    /// supplied (the caller reports it — an unusable server would be worse).
368    /// `--no-auth` is refused outright instead of being silently overridden.
369    /// An unscoped token is likewise defaulted rather than warned about: it is
370    /// scoped to the `operator` preset unless the consumer already chose a
371    /// scope.
372    ///
373    /// The remaining risk is a real but legitimate choice, so it is warned
374    /// about rather than blocked: rate limiting turned off.
375    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        // A default, not a warning. Warning about it is an admission that the
394        // default is wrong for the situation, and here the default can follow
395        // the situation instead.
396        //
397        // The actual reach is the same as `full-control` — `operator` already
398        // has `exec`, and `exec` reaches every file this process can reach.
399        // Only one thing changes: it does not automatically pick up
400        // capabilities added later. That is the wildcard's real danger.
401        //
402        // An explicit scope is left untouched. If the consumer chose it, that
403        // is the answer.
404        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        // The one warning left. This is a defense the consumer explicitly
410        // turned off, so a default cannot decide it on their behalf, and a
411        // warning is right.
412        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    /// Convert to ServerConfig for the API server.
423    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        // Apply auth settings
437        security.auth = AuthConfig {
438            enabled: self.security.auth.enabled,
439            ..AuthConfig::default()
440        };
441
442        // Apply rate limit settings
443        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        // Apply CORS settings (restrictive by default)
451        security.cors = CorsConfig {
452            allow_any: self.security.cors.allow_any,
453        };
454
455        // Resolve fine-grained token scoping (preset + capabilities).
456        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        // Add API keys
464        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    /// The capability set an issued token will actually carry.
479    ///
480    /// `None` means nothing narrowed it — the full-control default, which is
481    /// the wildcard. Resolved from the same two fields `to_server_config` uses
482    /// and through the same function, so a caller that wants to *describe* the
483    /// scope cannot drift from the one that enforces it. Call it after
484    /// `harden_for_public_exposure`, or the answer predates the promotion.
485    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    /// Get the log level filter string.
493    pub fn log_filter(&self) -> &str {
494        &self.logging.level
495    }
496}
497
498/// Resolve a `preset` name + explicit `capabilities` list into a capability set.
499///
500/// Returns `Ok(None)` when neither is given (full-control default). The preset
501/// (if any) forms the base set and the explicit capabilities are unioned on top.
502/// An unknown preset name is an error.
503fn 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); // Full-control (legacy-compatible) default.
509    }
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/// How far this process is exposed.
523///
524/// **Derived from arguments and not selectable by the user** — there is no option to choose
525/// a posture, and there should not be one. What has already been chosen (tunnel, relay, bind
526/// address) determines the posture, and the posture determines the security defaults.
527#[derive(Debug, Clone, Copy, PartialEq, Eq)]
528pub enum Posture {
529    /// Reachable only from this machine. No reason to narrow the defaults.
530    Local,
531    /// Reachable from other machines — one or more of: tunnel, relay, or non-loopback bind.
532    Exposed,
533}
534
535/// Outcome of hardening a configuration for public exposure.
536#[derive(Debug, Clone, Default)]
537pub struct PublicExposure {
538    /// Key generated because none was supplied — the only copy the user gets.
539    pub generated_key: Option<String>,
540    /// Risks that remain legitimate choices, reported rather than blocked.
541    pub warnings: Vec<String>,
542}
543
544/// Configuration errors.
545#[derive(Debug)]
546pub enum ConfigError {
547    /// IO error reading config file.
548    Io(std::io::Error),
549    /// JSON parsing error.
550    Json(serde_json::Error),
551    /// Invalid host address.
552    InvalidHost(String),
553    /// Unknown role preset name.
554    InvalidPreset(String),
555    /// A public reachability path was requested together with `--no-auth`.
556    RemoteWithoutAuth,
557    /// `transport.mode = "command"` without a command to run.
558    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                    // Names the config key as well as the flags: this error is
571                    // reached just as readily from `security.auth.preset` in a
572                    // config file, where an operator told to change a flag they
573                    // never passed has nowhere to look.
574                    "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"); // Default
649        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            // Both `_explicit` flags are what the parser sets when the flag is
658            // actually on the command line; a struct literal that sets only
659            // the value is describing a default, not a choice, and the two
660            // have to stay distinguishable here for the same reason
661            // `apply_args` distinguishes them.
662            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    /// A configured bind address and port survive when no flag names them.
684    ///
685    /// This test was the inverse: it pinned an unconditional assignment from
686    /// `Args`, whose defaults are `127.0.0.1` and `3000`, which overwrote a
687    /// configured value even when the user passed no flag at all. It was
688    /// written to be inverted — the documentation twice described a precedence
689    /// that was never implemented, and the only other test to touch a
690    /// configured port passed `-p`, which is exactly what hid the behaviour.
691    #[test]
692    fn a_configured_host_and_port_survive_when_no_flag_names_them() {
693        let mut config = Config::default();
694        // As a config file or `SHELL_TUNNEL_HOST`/`SHELL_TUNNEL_PORT` would
695        // leave it: `Config::load` runs `apply_env` before `apply_args`, so
696        // both arrive here indistinguishable from one another.
697        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(&nothing_passed);
706
707        assert_eq!(config.server.host, "0.0.0.0");
708        assert_eq!(config.server.port, 8080);
709    }
710
711    /// The flags still win when they are actually passed — the other half of
712    /// the same rule, and the half that was never broken.
713    #[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    /// The consequence worth pinning separately. `server.host` is not just a
732    /// bind address since 0.14.0 — it decides the security posture, and a
733    /// configured `0.0.0.0` that now actually takes effect makes the server
734    /// reachable, which forces authentication and an audit trail.
735    ///
736    /// It also re-checks the fail-closed property the old behaviour had by
737    /// accident: `posture()` and `to_server_config()` must read the same
738    /// field, so the posture can never describe a bind that did not happen.
739    #[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); // disabled by default
775
776        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        // Contradictory flags: explicit --no-auth wins.
788        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        // Specifying a scope (preset or capabilities) with no --api-key/--require-auth
823        // still turns auth on, so the server does not start open with the scope ignored.
824        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    /// Naming a scope on the command line replaces the file's scope entirely,
840    /// rather than being unioned on top of it.
841    ///
842    /// The union is right *within* one source — `--preset operator
843    /// --capabilities fs.read` on one command line is plainly a request to add
844    /// — but across sources it inverted the operator's intent: a file saying
845    /// `"preset": "operator"` plus a command line saying `--capabilities
846    /// fs.read` issued a token holding operator's whole set *and* `fs.read`,
847    /// `exec` still among them, when the command line was narrowing. A scope
848    /// input that cannot narrow is not a scope input.
849    #[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    /// The mirror case: a `capabilities` list in the file does not survive a
879    /// `--preset` either. Narrowing with `--preset` has to escape the union
880    /// from the same side.
881    #[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    /// Within one source the union stays: both given on one command line is a
899    /// request to add, and this is what keeps the replacement above from being
900    /// a blunt instrument.
901    #[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        // Explicit --no-auth wins even when a scope is given.
923        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        // The new AuthSection fields deserialize from a config file and flow
935        // through to a scoped SecurityConfig.
936        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")); // from file-read preset
959        assert!(caps.satisfies("exec")); // unioned explicit capability
960        assert!(!caps.satisfies("session.manage"));
961    }
962
963    #[test]
964    fn test_resolve_capabilities_none_by_default() {
965        // No preset, no capabilities -> full-control (None).
966        assert!(resolve_capabilities(None, &[]).unwrap().is_none());
967    }
968
969    #[test]
970    fn test_resolve_capabilities_preset_plus_extra() {
971        // file-read preset unioned with an explicit `exec`.
972        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        // `security.auth.preset` reaches this error too, and an operator who
1025        // set it there never passed a flag to correct.
1026        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        // The default handles the situation, so there is nothing left to warn about.
1157        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        // The actual reach is unchanged. What changes is one thing: it does not
1168        // automatically pick up capabilities added later. That is the wildcard's
1169        // real danger.
1170        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        // This is a risk the consumer explicitly chose, so a warning is right —
1222        // it is not the kind of thing a default can decide on their behalf.
1223        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        // Reached under a name we may not know; checking would only refuse
1248        // legitimate traffic.
1249        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        // No tunnel and no relay. Open to the LAN alone is exposure — reachable from another machine.
1357        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        // `to_server_config` already rejects this with `InvalidHost` at startup, so this
1378        // branch is not actually reachable. Even so, we fix the fail-closed direction — the
1379        // moment we read "unable to judge" as "safe", it becomes speculation, not proof.
1380        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}