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 = self.ensure_api_key();
386
387        // A default, not a warning. Warning about it is an admission that the
388        // default is wrong for the situation, and here the default can follow
389        // the situation instead.
390        //
391        // The actual reach is the same as `full-control` — `operator` already
392        // has `exec`, and `exec` reaches every file this process can reach.
393        // Only one thing changes: it does not automatically pick up
394        // capabilities added later. That is the wildcard's real danger.
395        //
396        // An explicit scope is left untouched. If the consumer chose it, that
397        // is the answer.
398        if self.security.auth.preset.is_none() && self.security.auth.capabilities.is_empty() {
399            self.security.auth.preset = Some("operator".to_string());
400        }
401
402        let mut warnings = Vec::new();
403        // The one warning left. This is a defense the consumer explicitly
404        // turned off, so a default cannot decide it on their behalf, and a
405        // warning is right.
406        if !self.security.rate_limit.enabled {
407            warnings.push("rate limiting is disabled on a publicly reachable server".to_string());
408        }
409
410        Ok(PublicExposure {
411            generated_key,
412            warnings,
413        })
414    }
415
416    /// Issue the API key this server will serve with, when authentication is
417    /// on and nothing supplied one.
418    ///
419    /// Returns the key that was generated — the only copy anyone gets — so the
420    /// caller can put it in front of the operator. `None` means there was
421    /// nothing to issue: a key was already supplied, or authentication is off.
422    /// Calling it twice is safe for the same reason.
423    ///
424    /// Issuing it here rather than inside the server is what makes it
425    /// printable. `serve_on` has no banner to print on, so a key created there
426    /// can only reach the operator as a `tracing` line — and that line is gone
427    /// at `-l warn` while the server still starts and still refuses every
428    /// request that does not carry the key nobody was told.
429    pub fn ensure_api_key(&mut self) -> Option<String> {
430        if !self.security.auth.enabled || !self.security.auth.api_keys.is_empty() {
431            return None;
432        }
433        let key = crate::security::generate_api_key();
434        self.security.auth.api_keys.push(key.clone());
435        Some(key)
436    }
437
438    /// Convert to ServerConfig for the API server.
439    pub fn to_server_config(&self) -> Result<ServerConfig, ConfigError> {
440        let host: IpAddr = self
441            .server
442            .host
443            .parse()
444            .map_err(|_| ConfigError::InvalidHost(self.server.host.clone()))?;
445
446        let mut security = if self.security.auth.enabled {
447            SecurityConfig::secure()
448        } else {
449            SecurityConfig::development()
450        };
451
452        // Apply auth settings
453        security.auth = AuthConfig {
454            enabled: self.security.auth.enabled,
455            ..AuthConfig::default()
456        };
457
458        // Apply rate limit settings
459        security.rate_limit = RateLimitConfig {
460            enabled: self.security.rate_limit.enabled,
461            max_requests: self.security.rate_limit.requests_per_window,
462            window: std::time::Duration::from_secs(self.security.rate_limit.window_secs),
463            max_tracked_ips: 10000,
464        };
465
466        // Apply CORS settings (restrictive by default)
467        security.cors = CorsConfig {
468            allow_any: self.security.cors.allow_any,
469        };
470
471        // Resolve fine-grained token scoping (preset + capabilities).
472        if let Some(capabilities) = resolve_capabilities(
473            self.security.auth.preset.as_deref(),
474            &self.security.auth.capabilities,
475        )? {
476            security = security.with_capabilities(capabilities);
477        }
478
479        // Add API keys
480        for key in &self.security.auth.api_keys {
481            security = security.with_api_key(key);
482        }
483
484        let mut server_config = ServerConfig::new(host.to_string(), self.server.port);
485        server_config = server_config.with_security(security);
486
487        if !self.server.graceful_shutdown {
488            server_config = server_config.without_graceful_shutdown();
489        }
490
491        Ok(server_config)
492    }
493
494    /// The capability set an issued token will actually carry.
495    ///
496    /// `None` means nothing narrowed it — the full-control default, which is
497    /// the wildcard. Resolved from the same two fields `to_server_config` uses
498    /// and through the same function, so a caller that wants to *describe* the
499    /// scope cannot drift from the one that enforces it. Call it after
500    /// `harden_for_public_exposure`, or the answer predates the promotion.
501    pub fn resolved_capabilities(&self) -> Result<Option<CapabilitySet>, ConfigError> {
502        resolve_capabilities(
503            self.security.auth.preset.as_deref(),
504            &self.security.auth.capabilities,
505        )
506    }
507
508    /// Get the log level filter string.
509    pub fn log_filter(&self) -> &str {
510        &self.logging.level
511    }
512}
513
514/// Resolve a `preset` name + explicit `capabilities` list into a capability set.
515///
516/// Returns `Ok(None)` when neither is given (full-control default). The preset
517/// (if any) forms the base set and the explicit capabilities are unioned on top.
518/// An unknown preset name is an error.
519fn resolve_capabilities(
520    preset: Option<&str>,
521    capabilities: &[String],
522) -> Result<Option<CapabilitySet>, ConfigError> {
523    if preset.is_none() && capabilities.is_empty() {
524        return Ok(None); // Full-control (legacy-compatible) default.
525    }
526
527    let mut set = match preset {
528        Some(name) => crate::security::preset(name)
529            .ok_or_else(|| ConfigError::InvalidPreset(name.to_string()))?,
530        None => CapabilitySet::new(),
531    };
532    for capability in capabilities {
533        set.insert(capability.clone());
534    }
535    Ok(Some(set))
536}
537
538/// How far this process is exposed.
539///
540/// **Derived from arguments and not selectable by the user** — there is no option to choose
541/// a posture, and there should not be one. What has already been chosen (tunnel, relay, bind
542/// address) determines the posture, and the posture determines the security defaults.
543#[derive(Debug, Clone, Copy, PartialEq, Eq)]
544pub enum Posture {
545    /// Reachable only from this machine. No reason to narrow the defaults.
546    Local,
547    /// Reachable from other machines — one or more of: tunnel, relay, or non-loopback bind.
548    Exposed,
549}
550
551/// Outcome of hardening a configuration for public exposure.
552#[derive(Debug, Clone, Default)]
553pub struct PublicExposure {
554    /// Key generated because none was supplied — the only copy the user gets.
555    pub generated_key: Option<String>,
556    /// Risks that remain legitimate choices, reported rather than blocked.
557    pub warnings: Vec<String>,
558}
559
560/// Configuration errors.
561#[derive(Debug)]
562pub enum ConfigError {
563    /// IO error reading config file.
564    Io(std::io::Error),
565    /// JSON parsing error.
566    Json(serde_json::Error),
567    /// Invalid host address.
568    InvalidHost(String),
569    /// Unknown role preset name.
570    InvalidPreset(String),
571    /// A public reachability path was requested together with `--no-auth`.
572    RemoteWithoutAuth,
573    /// `transport.mode = "command"` without a command to run.
574    MissingTunnelCommand,
575}
576
577impl std::fmt::Display for ConfigError {
578    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
579        match self {
580            Self::Io(e) => write!(f, "failed to read config file: {}", e),
581            Self::Json(e) => write!(f, "failed to parse config file: {}", e),
582            Self::InvalidHost(host) => write!(f, "invalid host address: {}", host),
583            Self::InvalidPreset(name) if name == "read-only" => {
584                write!(
585                    f,
586                    // Names the config key as well as the flags: this error is
587                    // reached just as readily from `security.auth.preset` in a
588                    // config file, where an operator told to change a flag they
589                    // never passed has nowhere to look.
590                    "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"
591                )
592            }
593            Self::InvalidPreset(name) => write!(
594                f,
595                "unknown role preset: '{}' (expected operator, file-write, file-read, or full-control)",
596                name
597            ),
598            Self::MissingTunnelCommand => write!(
599                f,
600                "transport.mode is \"command\" but transport.command is not set (or use --tunnel-command)"
601            ),
602            Self::RemoteWithoutAuth => write!(
603                f,
604                "--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"
605            ),
606        }
607    }
608}
609
610impl std::error::Error for ConfigError {}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use std::io::Write;
616    use tempfile::NamedTempFile;
617
618    #[test]
619    fn test_default_config() {
620        let config = Config::default();
621        assert_eq!(config.server.host, "127.0.0.1");
622        assert_eq!(config.server.port, 3000);
623        assert!(!config.security.auth.enabled);
624        assert!(config.security.rate_limit.enabled);
625    }
626
627    #[test]
628    fn test_config_from_json() {
629        let json = r#"{
630            "server": {
631                "host": "0.0.0.0",
632                "port": 8080
633            },
634            "security": {
635                "auth": {
636                    "enabled": true,
637                    "api_keys": ["key1", "key2"]
638                }
639            }
640        }"#;
641
642        let mut file = NamedTempFile::new().unwrap();
643        file.write_all(json.as_bytes()).unwrap();
644
645        let config = Config::from_file(file.path()).unwrap();
646        assert_eq!(config.server.host, "0.0.0.0");
647        assert_eq!(config.server.port, 8080);
648        assert!(config.security.auth.enabled);
649        assert_eq!(config.security.auth.api_keys.len(), 2);
650    }
651
652    #[test]
653    fn test_config_partial_json() {
654        let json = r#"{
655            "server": {
656                "port": 9000
657            }
658        }"#;
659
660        let mut file = NamedTempFile::new().unwrap();
661        file.write_all(json.as_bytes()).unwrap();
662
663        let config = Config::from_file(file.path()).unwrap();
664        assert_eq!(config.server.host, "127.0.0.1"); // Default
665        assert_eq!(config.server.port, 9000);
666    }
667
668    #[test]
669    fn test_apply_args() {
670        let mut config = Config::default();
671        let args = Args {
672            host: "192.168.1.1".parse().unwrap(),
673            // Both `_explicit` flags are what the parser sets when the flag is
674            // actually on the command line; a struct literal that sets only
675            // the value is describing a default, not a choice, and the two
676            // have to stay distinguishable here for the same reason
677            // `apply_args` distinguishes them.
678            host_explicit: true,
679            port: 5000,
680            port_explicit: true,
681            api_key: Some("test-key".to_string()),
682            no_rate_limit: true,
683            ..Args::default()
684        };
685
686        config.apply_args(&args);
687
688        assert_eq!(config.server.host, "192.168.1.1");
689        assert_eq!(config.server.port, 5000);
690        assert!(config.security.auth.enabled);
691        assert!(config
692            .security
693            .auth
694            .api_keys
695            .contains(&"test-key".to_string()));
696        assert!(!config.security.rate_limit.enabled);
697    }
698
699    /// A configured bind address and port survive when no flag names them.
700    ///
701    /// This test was the inverse: it pinned an unconditional assignment from
702    /// `Args`, whose defaults are `127.0.0.1` and `3000`, which overwrote a
703    /// configured value even when the user passed no flag at all. It was
704    /// written to be inverted — the documentation twice described a precedence
705    /// that was never implemented, and the only other test to touch a
706    /// configured port passed `-p`, which is exactly what hid the behaviour.
707    #[test]
708    fn a_configured_host_and_port_survive_when_no_flag_names_them() {
709        let mut config = Config::default();
710        // As a config file or `SHELL_TUNNEL_HOST`/`SHELL_TUNNEL_PORT` would
711        // leave it: `Config::load` runs `apply_env` before `apply_args`, so
712        // both arrive here indistinguishable from one another.
713        config.server.host = "0.0.0.0".to_string();
714        config.server.port = 8080;
715
716        let nothing_passed = Args::default();
717        assert!(
718            !nothing_passed.port_explicit && !nothing_passed.host_explicit,
719            "the premise: no flag was given"
720        );
721        config.apply_args(&nothing_passed);
722
723        assert_eq!(config.server.host, "0.0.0.0");
724        assert_eq!(config.server.port, 8080);
725    }
726
727    /// The flags still win when they are actually passed — the other half of
728    /// the same rule, and the half that was never broken.
729    #[test]
730    fn a_named_host_and_port_beat_the_configured_ones() {
731        let mut config = Config::default();
732        config.server.host = "0.0.0.0".to_string();
733        config.server.port = 8080;
734
735        config.apply_args(&Args {
736            host: "10.0.0.5".parse().expect("addr"),
737            host_explicit: true,
738            port: 9999,
739            port_explicit: true,
740            ..Args::default()
741        });
742
743        assert_eq!(config.server.host, "10.0.0.5");
744        assert_eq!(config.server.port, 9999);
745    }
746
747    /// The consequence worth pinning separately. `server.host` is not just a
748    /// bind address since 0.14.0 — it decides the security posture, and a
749    /// configured `0.0.0.0` that now actually takes effect makes the server
750    /// reachable, which forces authentication and an audit trail.
751    ///
752    /// It also re-checks the fail-closed property the old behaviour had by
753    /// accident: `posture()` and `to_server_config()` must read the same
754    /// field, so the posture can never describe a bind that did not happen.
755    #[test]
756    fn a_configured_non_loopback_host_now_decides_the_posture() {
757        let mut config = Config::default();
758        config.server.host = "0.0.0.0".to_string();
759        config.apply_args(&Args::default());
760
761        assert_eq!(
762            config.posture(false, false),
763            Posture::Exposed,
764            "a bind address that now takes effect must also be seen by the posture"
765        );
766        let server = config.to_server_config().expect("valid config");
767        assert_eq!(
768            server.host, "0.0.0.0",
769            "the posture and the listener must read the same field"
770        );
771    }
772
773    #[test]
774    fn test_apply_no_auth() {
775        let mut config = Config::default();
776        config.security.auth.enabled = true;
777
778        let args = Args {
779            no_auth: true,
780            ..Args::default()
781        };
782
783        config.apply_args(&args);
784        assert!(!config.security.auth.enabled);
785    }
786
787    #[test]
788    fn test_apply_require_auth() {
789        let mut config = Config::default();
790        assert!(!config.security.auth.enabled); // disabled by default
791
792        config.apply_args(&Args {
793            require_auth: true,
794            ..Args::default()
795        });
796        assert!(config.security.auth.enabled);
797    }
798
799    #[test]
800    fn test_no_auth_overrides_require_auth() {
801        let mut config = Config::default();
802
803        // Contradictory flags: explicit --no-auth wins.
804        config.apply_args(&Args {
805            require_auth: true,
806            no_auth: true,
807            ..Args::default()
808        });
809        assert!(!config.security.auth.enabled);
810    }
811
812    #[test]
813    fn test_to_server_config() {
814        let config = Config::default();
815        let server_config = config.to_server_config().unwrap();
816
817        assert_eq!(server_config.host, "127.0.0.1");
818        assert_eq!(server_config.port, 3000);
819    }
820
821    #[test]
822    fn test_apply_args_capabilities_and_preset() {
823        let mut config = Config::default();
824        config.apply_args(&Args {
825            capabilities: vec!["exec".to_string(), "session.read".to_string()],
826            preset: Some("operator".to_string()),
827            ..Args::default()
828        });
829        assert_eq!(
830            config.security.auth.capabilities,
831            vec!["exec", "session.read"]
832        );
833        assert_eq!(config.security.auth.preset, Some("operator".to_string()));
834    }
835
836    #[test]
837    fn test_scope_implies_auth_on() {
838        // Specifying a scope (preset or capabilities) with no --api-key/--require-auth
839        // still turns auth on, so the server does not start open with the scope ignored.
840        let mut by_preset = Config::default();
841        by_preset.apply_args(&Args {
842            preset: Some("file-read".to_string()),
843            ..Args::default()
844        });
845        assert!(by_preset.security.auth.enabled);
846
847        let mut by_caps = Config::default();
848        by_caps.apply_args(&Args {
849            capabilities: vec!["session.read".to_string()],
850            ..Args::default()
851        });
852        assert!(by_caps.security.auth.enabled);
853    }
854
855    /// Naming a scope on the command line replaces the file's scope entirely,
856    /// rather than being unioned on top of it.
857    ///
858    /// The union is right *within* one source — `--preset operator
859    /// --capabilities fs.read` on one command line is plainly a request to add
860    /// — but across sources it inverted the operator's intent: a file saying
861    /// `"preset": "operator"` plus a command line saying `--capabilities
862    /// fs.read` issued a token holding operator's whole set *and* `fs.read`,
863    /// `exec` still among them, when the command line was narrowing. A scope
864    /// input that cannot narrow is not a scope input.
865    #[test]
866    fn a_scope_named_on_the_command_line_replaces_the_files_scope() {
867        let mut config = Config::default();
868        config.security.auth.preset = Some("operator".to_string());
869
870        config.apply_args(&Args {
871            capabilities: vec!["fs.read".to_string()],
872            ..Args::default()
873        });
874
875        assert_eq!(
876            config.security.auth.preset, None,
877            "the file's preset must not survive a scope named on the command line"
878        );
879        assert_eq!(config.security.auth.capabilities, vec!["fs.read"]);
880        assert_eq!(
881            resolve_capabilities(
882                config.security.auth.preset.as_deref(),
883                &config.security.auth.capabilities,
884            )
885            .expect("valid")
886            .expect("a scope was named")
887            .iter()
888            .collect::<Vec<_>>(),
889            vec!["fs.read"],
890            "and the resolved set is what was asked for, with no exec left in it"
891        );
892    }
893
894    /// The mirror case: a `capabilities` list in the file does not survive a
895    /// `--preset` either. Narrowing with `--preset` has to escape the union
896    /// from the same side.
897    #[test]
898    fn a_preset_named_on_the_command_line_replaces_the_files_capabilities() {
899        let mut config = Config::default();
900        config.security.auth.capabilities = vec!["exec".to_string()];
901
902        config.apply_args(&Args {
903            preset: Some("file-read".to_string()),
904            ..Args::default()
905        });
906
907        assert!(
908            config.security.auth.capabilities.is_empty(),
909            "the file's capability list must not survive a preset named on the command line"
910        );
911        assert_eq!(config.security.auth.preset, Some("file-read".to_string()));
912    }
913
914    /// Within one source the union stays: both given on one command line is a
915    /// request to add, and this is what keeps the replacement above from being
916    /// a blunt instrument.
917    #[test]
918    fn a_preset_and_capabilities_on_one_command_line_still_union() {
919        let mut config = Config::default();
920        config.apply_args(&Args {
921            preset: Some("file-read".to_string()),
922            capabilities: vec!["session.read".to_string()],
923            ..Args::default()
924        });
925
926        let resolved = resolve_capabilities(
927            config.security.auth.preset.as_deref(),
928            &config.security.auth.capabilities,
929        )
930        .expect("valid")
931        .expect("a scope was named");
932        assert!(resolved.satisfies("fs.read"), "from the preset");
933        assert!(resolved.satisfies("session.read"), "from the list");
934    }
935
936    #[test]
937    fn test_no_auth_overrides_scope_implied_auth() {
938        // Explicit --no-auth wins even when a scope is given.
939        let mut config = Config::default();
940        config.apply_args(&Args {
941            preset: Some("file-read".to_string()),
942            no_auth: true,
943            ..Args::default()
944        });
945        assert!(!config.security.auth.enabled);
946    }
947
948    #[test]
949    fn test_config_from_json_with_capabilities_and_preset() {
950        // The new AuthSection fields deserialize from a config file and flow
951        // through to a scoped SecurityConfig.
952        let json = r#"{
953            "security": {
954                "auth": {
955                    "enabled": true,
956                    "api_keys": ["scoped"],
957                    "preset": "file-read",
958                    "capabilities": ["exec"]
959                }
960            }
961        }"#;
962        let mut file = NamedTempFile::new().unwrap();
963        file.write_all(json.as_bytes()).unwrap();
964
965        let config = Config::from_file(file.path()).unwrap();
966        assert_eq!(config.security.auth.preset, Some("file-read".to_string()));
967        assert_eq!(config.security.auth.capabilities, vec!["exec"]);
968
969        let server_config = config.to_server_config().unwrap();
970        let caps = server_config
971            .security
972            .capabilities
973            .expect("capabilities scoped from file");
974        assert!(caps.satisfies("fs.read")); // from file-read preset
975        assert!(caps.satisfies("exec")); // unioned explicit capability
976        assert!(!caps.satisfies("session.manage"));
977    }
978
979    #[test]
980    fn test_resolve_capabilities_none_by_default() {
981        // No preset, no capabilities -> full-control (None).
982        assert!(resolve_capabilities(None, &[]).unwrap().is_none());
983    }
984
985    #[test]
986    fn test_resolve_capabilities_preset_plus_extra() {
987        // file-read preset unioned with an explicit `exec`.
988        let set = resolve_capabilities(Some("file-read"), &["exec".to_string()])
989            .unwrap()
990            .unwrap();
991        assert!(set.satisfies("fs.read"));
992        assert!(set.satisfies("exec"));
993        assert!(!set.satisfies("session.manage"));
994    }
995
996    #[test]
997    fn test_resolve_capabilities_invalid_preset_errors() {
998        let err = resolve_capabilities(Some("superuser"), &[]);
999        assert!(matches!(err, Err(ConfigError::InvalidPreset(_))));
1000    }
1001
1002    #[test]
1003    fn test_to_server_config_scopes_capabilities() {
1004        let mut config = Config::default();
1005        config.security.auth.enabled = true;
1006        config.security.auth.api_keys = vec!["scoped".to_string()];
1007        config.security.auth.preset = Some("file-read".to_string());
1008
1009        let server_config = config.to_server_config().unwrap();
1010        let caps = server_config
1011            .security
1012            .capabilities
1013            .expect("capabilities scoped");
1014        assert!(caps.satisfies("fs.read"));
1015        assert!(!caps.satisfies("exec"));
1016    }
1017
1018    #[test]
1019    fn test_to_server_config_invalid_preset_errors() {
1020        let mut config = Config::default();
1021        config.security.auth.preset = Some("root".to_string());
1022        assert!(matches!(
1023            config.to_server_config(),
1024            Err(ConfigError::InvalidPreset(_))
1025        ));
1026    }
1027
1028    #[test]
1029    fn the_read_only_refusal_names_its_replacement() {
1030        let err = ConfigError::InvalidPreset("read-only".to_string());
1031        let message = err.to_string();
1032        assert!(
1033            message.contains("file-read"),
1034            "must point at the replacement: {message}"
1035        );
1036        assert!(
1037            message.contains("session.read"),
1038            "must offer the exact escape: {message}"
1039        );
1040        // `security.auth.preset` reaches this error too, and an operator who
1041        // set it there never passed a flag to correct.
1042        assert!(
1043            message.contains("security.auth.preset"),
1044            "must name the config key, not only the flags: {message}"
1045        );
1046    }
1047
1048    #[test]
1049    fn an_unknown_preset_lists_the_valid_ones() {
1050        let err = ConfigError::InvalidPreset("nonsense".to_string());
1051        let message = err.to_string();
1052        for name in ["operator", "file-write", "file-read", "full-control"] {
1053            assert!(message.contains(name), "must list {name}: {message}");
1054        }
1055        assert!(
1056            !message.contains("read-only"),
1057            "must not advertise a removed preset: {message}"
1058        );
1059    }
1060
1061    #[test]
1062    fn test_invalid_host() {
1063        let mut config = Config::default();
1064        config.server.host = "not-an-ip".to_string();
1065
1066        let result = config.to_server_config();
1067        assert!(result.is_err());
1068    }
1069
1070    #[test]
1071    fn test_config_serialization() {
1072        let config = Config::default();
1073        let json = serde_json::to_string_pretty(&config).unwrap();
1074        assert!(json.contains("\"host\""));
1075        assert!(json.contains("\"port\""));
1076    }
1077
1078    fn tunnel_args() -> Args {
1079        Args {
1080            tunnel: true,
1081            ..Default::default()
1082        }
1083    }
1084
1085    #[test]
1086    fn test_public_exposure_refuses_no_auth() {
1087        let mut config = Config::default();
1088        let args = Args {
1089            no_auth: true,
1090            ..tunnel_args()
1091        };
1092        let err = config.harden_for_public_exposure(&args).unwrap_err();
1093        assert!(matches!(err, ConfigError::RemoteWithoutAuth));
1094        assert!(err.to_string().contains("unauthenticated shell"));
1095    }
1096
1097    #[test]
1098    fn test_public_exposure_enables_auth_and_generates_a_key() {
1099        let mut config = Config::default();
1100        assert!(!config.security.auth.enabled);
1101
1102        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1103
1104        assert!(config.security.auth.enabled);
1105        let key = exposure.generated_key.expect("a key must be generated");
1106        assert!(key.starts_with("st_"));
1107        assert_eq!(config.security.auth.api_keys, vec![key]);
1108    }
1109
1110    #[test]
1111    fn test_public_exposure_keeps_a_supplied_key() {
1112        let mut config = Config::default();
1113        config.security.auth.api_keys.push("my-key".to_string());
1114
1115        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1116
1117        assert!(exposure.generated_key.is_none());
1118        assert_eq!(config.security.auth.api_keys, vec!["my-key".to_string()]);
1119    }
1120
1121    #[test]
1122    fn test_public_exposure_no_longer_warns_about_an_unscoped_token_because_it_scopes_it() {
1123        let mut config = Config::default();
1124        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1125        assert!(
1126            !exposure.warnings.iter().any(|w| w.contains("full control")),
1127            "{:?}",
1128            exposure.warnings
1129        );
1130    }
1131
1132    #[test]
1133    fn test_public_exposure_does_not_warn_about_a_scoped_token() {
1134        let mut config = Config::default();
1135        config.security.auth.preset = Some("operator".to_string());
1136        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1137        assert!(
1138            !exposure.warnings.iter().any(|w| w.contains("full control")),
1139            "{:?}",
1140            exposure.warnings
1141        );
1142    }
1143
1144    #[test]
1145    fn test_public_exposure_warns_about_disabled_rate_limit() {
1146        let mut config = Config::default();
1147        config.security.rate_limit.enabled = false;
1148
1149        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1150
1151        assert!(exposure
1152            .warnings
1153            .iter()
1154            .any(|w| w.contains("rate limiting")));
1155    }
1156
1157    #[test]
1158    fn test_public_exposure_is_quiet_on_a_scoped_loopback_setup() {
1159        let mut config = Config::default();
1160        config.security.auth.preset = Some("operator".to_string());
1161        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1162        assert!(exposure.warnings.is_empty(), "{:?}", exposure.warnings);
1163    }
1164
1165    #[test]
1166    fn exposure_scopes_the_issued_token_instead_of_warning_about_it() {
1167        let mut config = Config::default();
1168        assert!(config.security.auth.preset.is_none());
1169
1170        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1171
1172        // The default handles the situation, so there is nothing left to warn about.
1173        assert_eq!(config.security.auth.preset.as_deref(), Some("operator"));
1174        assert!(
1175            !exposure.warnings.iter().any(|w| w.contains("full control")),
1176            "the warning must be gone, not merely reworded: {:?}",
1177            exposure.warnings
1178        );
1179    }
1180
1181    #[test]
1182    fn the_exposed_token_is_not_a_wildcard() {
1183        // The actual reach is unchanged. What changes is one thing: it does not
1184        // automatically pick up capabilities added later. That is the wildcard's
1185        // real danger.
1186        let mut config = Config::default();
1187        config.harden_for_public_exposure(&tunnel_args()).unwrap();
1188
1189        let set = resolve_capabilities(
1190            config.security.auth.preset.as_deref(),
1191            &config.security.auth.capabilities,
1192        )
1193        .unwrap()
1194        .expect("an exposed token must have an explicit set");
1195        assert!(!set.is_wildcard());
1196        assert!(set.satisfies("exec"));
1197        assert!(set.satisfies("fs.write"));
1198    }
1199
1200    #[test]
1201    fn an_explicit_scope_is_left_alone() {
1202        let mut config = Config::default();
1203        config.security.auth.preset = Some("file-read".to_string());
1204
1205        config.harden_for_public_exposure(&tunnel_args()).unwrap();
1206
1207        assert_eq!(config.security.auth.preset.as_deref(), Some("file-read"));
1208    }
1209
1210    #[test]
1211    fn explicit_capabilities_are_left_alone_too() {
1212        let mut config = Config::default();
1213        config.security.auth.capabilities = vec!["exec".to_string()];
1214
1215        config.harden_for_public_exposure(&tunnel_args()).unwrap();
1216
1217        assert!(config.security.auth.preset.is_none());
1218        assert_eq!(config.security.auth.capabilities, vec!["exec".to_string()]);
1219    }
1220
1221    #[test]
1222    fn a_non_loopback_bind_no_longer_warns_because_it_now_decides_the_posture() {
1223        let mut config = Config::default();
1224        config.server.host = "0.0.0.0".to_string();
1225
1226        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1227
1228        assert!(
1229            !exposure.warnings.iter().any(|w| w.contains("binding")),
1230            "posture covers this now: {:?}",
1231            exposure.warnings
1232        );
1233    }
1234
1235    #[test]
1236    fn a_disabled_rate_limit_still_warns() {
1237        // This is a risk the consumer explicitly chose, so a warning is right —
1238        // it is not the kind of thing a default can decide on their behalf.
1239        let mut config = Config::default();
1240        config.security.rate_limit.enabled = false;
1241
1242        let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
1243
1244        assert!(exposure
1245            .warnings
1246            .iter()
1247            .any(|w| w.contains("rate limiting")));
1248    }
1249
1250    #[test]
1251    fn a_loopback_server_answers_only_to_local_names() {
1252        let config = Config::default();
1253        let hosts = config
1254            .allowed_hosts(&Args::default(), false)
1255            .expect("a loopback server gets a list");
1256
1257        assert!(hosts.contains(&"localhost".to_string()));
1258        assert!(hosts.contains(&"127.0.0.1".to_string()));
1259    }
1260
1261    #[test]
1262    fn a_published_server_is_not_host_checked() {
1263        // Reached under a name we may not know; checking would only refuse
1264        // legitimate traffic.
1265        let config = Config::default();
1266        assert!(config.allowed_hosts(&Args::default(), true).is_none());
1267    }
1268
1269    #[test]
1270    fn a_non_loopback_bind_is_not_host_checked() {
1271        let mut config = Config::default();
1272        config.server.host = "0.0.0.0".to_string();
1273        assert!(config.allowed_hosts(&Args::default(), false).is_none());
1274    }
1275
1276    #[test]
1277    fn extra_allowed_hosts_join_the_defaults() {
1278        let config = Config::default();
1279        let args = Args {
1280            allow_hosts: vec!["myapp.internal".to_string()],
1281            ..Default::default()
1282        };
1283        let hosts = config.allowed_hosts(&args, false).unwrap();
1284
1285        assert!(hosts.contains(&"myapp.internal".to_string()));
1286        assert!(hosts.contains(&"localhost".to_string()));
1287    }
1288
1289    #[test]
1290    fn test_transport_defaults_to_local_only() {
1291        let config = Config::default();
1292        assert_eq!(config.transport.mode, TransportMode::None);
1293        assert!(config.tunnel_provider().unwrap().is_none());
1294    }
1295
1296    #[test]
1297    fn test_transport_mode_from_config_file() {
1298        let json = r#"{"transport":{"mode":"cloudflared"}}"#;
1299        let config: Config = serde_json::from_str(json).unwrap();
1300        assert_eq!(config.transport.mode, TransportMode::Cloudflared);
1301        let provider = config.tunnel_provider().unwrap().expect("a provider");
1302        assert_eq!(provider.name(), "cloudflared");
1303    }
1304
1305    #[test]
1306    fn test_transport_command_from_config_file() {
1307        let json = r#"{"transport":{"mode":"command","command":"ngrok http 3000"}}"#;
1308        let config: Config = serde_json::from_str(json).unwrap();
1309        let provider = config.tunnel_provider().unwrap().expect("a provider");
1310        assert_eq!(provider.name(), "tunnel-command");
1311    }
1312
1313    #[test]
1314    fn test_transport_command_mode_requires_a_command() {
1315        let json = r#"{"transport":{"mode":"command"}}"#;
1316        let config: Config = serde_json::from_str(json).unwrap();
1317        let err = config.tunnel_provider().unwrap_err();
1318        assert!(matches!(err, ConfigError::MissingTunnelCommand));
1319        assert!(err.to_string().contains("transport.command"));
1320    }
1321
1322    #[test]
1323    fn test_cli_tunnel_overrides_config_file() {
1324        let mut config: Config =
1325            serde_json::from_str(r#"{"transport":{"mode":"command","command":"old"}}"#).unwrap();
1326        config.apply_args(&Args {
1327            tunnel: true,
1328            ..Default::default()
1329        });
1330        assert_eq!(config.transport.mode, TransportMode::Cloudflared);
1331    }
1332
1333    #[test]
1334    fn test_cli_tunnel_command_overrides_config_file() {
1335        let mut config: Config =
1336            serde_json::from_str(r#"{"transport":{"mode":"cloudflared"}}"#).unwrap();
1337        config.apply_args(&Args {
1338            tunnel_command: Some("bore local 3000 --to bore.pub".to_string()),
1339            ..Default::default()
1340        });
1341        assert_eq!(config.transport.mode, TransportMode::Command);
1342        assert_eq!(
1343            config.transport.command.as_deref(),
1344            Some("bore local 3000 --to bore.pub")
1345        );
1346    }
1347
1348    #[test]
1349    fn test_config_file_transport_survives_unrelated_args() {
1350        let mut config: Config =
1351            serde_json::from_str(r#"{"transport":{"mode":"cloudflared"}}"#).unwrap();
1352        config.apply_args(&Args::default());
1353        assert_eq!(config.transport.mode, TransportMode::Cloudflared);
1354    }
1355
1356    #[test]
1357    fn loopback_bind_without_a_public_path_is_local() {
1358        let config = Config::default();
1359        assert_eq!(config.server.host, "127.0.0.1");
1360        assert_eq!(config.posture(false, false), Posture::Local);
1361    }
1362
1363    #[test]
1364    fn a_tunnel_or_a_relay_makes_it_exposed() {
1365        let config = Config::default();
1366        assert_eq!(config.posture(true, false), Posture::Exposed);
1367        assert_eq!(config.posture(false, true), Posture::Exposed);
1368    }
1369
1370    #[test]
1371    fn a_non_loopback_bind_is_exposed_on_its_own() {
1372        // No tunnel and no relay. Open to the LAN alone is exposure — reachable from another machine.
1373        let mut config = Config::default();
1374        config.server.host = "0.0.0.0".to_string();
1375        assert_eq!(config.posture(false, false), Posture::Exposed);
1376
1377        config.server.host = "192.168.1.10".to_string();
1378        assert_eq!(config.posture(false, false), Posture::Exposed);
1379
1380        config.server.host = "::".to_string();
1381        assert_eq!(config.posture(false, false), Posture::Exposed);
1382    }
1383
1384    #[test]
1385    fn ipv6_loopback_is_local() {
1386        let mut config = Config::default();
1387        config.server.host = "::1".to_string();
1388        assert_eq!(config.posture(false, false), Posture::Local);
1389    }
1390
1391    #[test]
1392    fn an_unparseable_host_is_exposed_rather_than_local() {
1393        // `to_server_config` already rejects this with `InvalidHost` at startup, so this
1394        // branch is not actually reachable. Even so, we fix the fail-closed direction — the
1395        // moment we read "unable to judge" as "safe", it becomes speculation, not proof.
1396        let mut config = Config::default();
1397        config.server.host = "not-an-ip".to_string();
1398        assert_eq!(config.posture(false, false), Posture::Exposed);
1399    }
1400}