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};
17
18/// Application configuration.
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20#[serde(default)]
21pub struct Config {
22    /// Server configuration.
23    pub server: ServerSection,
24    /// Security configuration.
25    pub security: SecuritySection,
26    /// Logging configuration.
27    pub logging: LoggingSection,
28}
29
30/// Server configuration section.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(default)]
33pub struct ServerSection {
34    /// Host address to bind to.
35    pub host: String,
36    /// Port to listen on.
37    pub port: u16,
38    /// Enable graceful shutdown.
39    pub graceful_shutdown: bool,
40}
41
42impl Default for ServerSection {
43    fn default() -> Self {
44        Self {
45            host: "127.0.0.1".to_string(),
46            port: 3000,
47            graceful_shutdown: true,
48        }
49    }
50}
51
52/// Security configuration section.
53#[derive(Debug, Clone, Default, Serialize, Deserialize)]
54#[serde(default)]
55pub struct SecuritySection {
56    /// Authentication settings.
57    pub auth: AuthSection,
58    /// Rate limiting settings.
59    pub rate_limit: RateLimitSection,
60    /// CORS settings.
61    pub cors: CorsSection,
62}
63
64/// CORS configuration section.
65#[derive(Debug, Clone, Default, Serialize, Deserialize)]
66#[serde(default)]
67pub struct CorsSection {
68    /// Allow any origin (permissive CORS). Off by default; enable only for
69    /// trusted browser-based UIs.
70    pub allow_any: bool,
71}
72
73/// Authentication configuration.
74#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75#[serde(default)]
76pub struct AuthSection {
77    /// Enable authentication.
78    pub enabled: bool,
79    /// API keys.
80    pub api_keys: Vec<String>,
81    /// Capability strings scoping the keys (empty = full-control).
82    pub capabilities: Vec<String>,
83    /// Role preset scoping the keys (operator/read-only/full-control).
84    pub preset: Option<String>,
85}
86
87/// Rate limiting configuration.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(default)]
90pub struct RateLimitSection {
91    /// Enable rate limiting.
92    pub enabled: bool,
93    /// Requests per window.
94    pub requests_per_window: u32,
95    /// Window size in seconds.
96    pub window_secs: u64,
97}
98
99impl Default for RateLimitSection {
100    fn default() -> Self {
101        Self {
102            enabled: true,
103            requests_per_window: 100,
104            window_secs: 60,
105        }
106    }
107}
108
109/// Logging configuration section.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(default)]
112pub struct LoggingSection {
113    /// Log level (error, warn, info, debug, trace).
114    pub level: String,
115}
116
117impl Default for LoggingSection {
118    fn default() -> Self {
119        Self {
120            level: "info".to_string(),
121        }
122    }
123}
124
125impl Config {
126    /// Load configuration from a JSON file.
127    pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
128        let content = std::fs::read_to_string(path).map_err(ConfigError::Io)?;
129        serde_json::from_str(&content).map_err(ConfigError::Json)
130    }
131
132    /// Apply environment variable overrides.
133    pub fn apply_env(&mut self) {
134        if let Ok(host) = std::env::var("SHELL_TUNNEL_HOST") {
135            self.server.host = host;
136        }
137
138        if let Ok(port) = std::env::var("SHELL_TUNNEL_PORT") {
139            if let Ok(port) = port.parse() {
140                self.server.port = port;
141            }
142        }
143
144        if let Ok(key) = std::env::var("SHELL_TUNNEL_API_KEY") {
145            if !key.is_empty() {
146                self.security.auth.enabled = true;
147                if !self.security.auth.api_keys.contains(&key) {
148                    self.security.auth.api_keys.push(key);
149                }
150            }
151        }
152
153        if let Ok(level) = std::env::var("SHELL_TUNNEL_LOG_LEVEL") {
154            self.logging.level = level;
155        } else if let Ok(level) = std::env::var("RUST_LOG") {
156            self.logging.level = level;
157        }
158    }
159
160    /// Apply CLI argument overrides.
161    pub fn apply_args(&mut self, args: &Args) {
162        self.server.host = args.host.to_string();
163        self.server.port = args.port;
164
165        if let Some(ref key) = args.api_key {
166            self.security.auth.enabled = true;
167            if !self.security.auth.api_keys.contains(key) {
168                self.security.auth.api_keys.push(key.clone());
169            }
170        }
171
172        // Enable auth on request (a key is auto-generated at startup if none is set).
173        // Applied before `no_auth` so an explicit `--no-auth` still wins.
174        if args.require_auth {
175            self.security.auth.enabled = true;
176        }
177
178        // Token scoping (fine-grained capabilities / preset). Specifying a scope
179        // implies auth-on — otherwise the scope would be silently ignored and the
180        // server would start open, the opposite of what `--preset read-only` asks
181        // for. Applied before `no_auth` so an explicit `--no-auth` still wins.
182        if !args.capabilities.is_empty() {
183            self.security.auth.capabilities = args.capabilities.clone();
184            self.security.auth.enabled = true;
185        }
186        if let Some(ref preset) = args.preset {
187            self.security.auth.preset = Some(preset.clone());
188            self.security.auth.enabled = true;
189        }
190
191        if args.no_auth {
192            self.security.auth.enabled = false;
193        }
194
195        if args.no_rate_limit {
196            self.security.rate_limit.enabled = false;
197        }
198
199        if args.cors_allow_any {
200            self.security.cors.allow_any = true;
201        }
202
203        if let Some(ref level) = args.log_level {
204            self.logging.level = level.clone();
205        }
206    }
207
208    /// Load configuration with full priority chain.
209    ///
210    /// Priority: CLI args > env vars > config file > defaults
211    pub fn load(args: &Args) -> Result<Self, ConfigError> {
212        // Start with defaults
213        let mut config = Config::default();
214
215        // Load from config file if specified
216        if let Some(ref path) = args.config {
217            config = Config::from_file(path)?;
218        }
219
220        // Apply environment variable overrides
221        config.apply_env();
222
223        // Apply CLI argument overrides (highest priority)
224        config.apply_args(args);
225
226        Ok(config)
227    }
228
229    /// Convert to ServerConfig for the API server.
230    pub fn to_server_config(&self) -> Result<ServerConfig, ConfigError> {
231        let host: IpAddr = self
232            .server
233            .host
234            .parse()
235            .map_err(|_| ConfigError::InvalidHost(self.server.host.clone()))?;
236
237        let mut security = if self.security.auth.enabled {
238            SecurityConfig::secure()
239        } else {
240            SecurityConfig::development()
241        };
242
243        // Apply auth settings
244        security.auth = AuthConfig {
245            enabled: self.security.auth.enabled,
246            ..AuthConfig::default()
247        };
248
249        // Apply rate limit settings
250        security.rate_limit = RateLimitConfig {
251            enabled: self.security.rate_limit.enabled,
252            max_requests: self.security.rate_limit.requests_per_window,
253            window: std::time::Duration::from_secs(self.security.rate_limit.window_secs),
254            max_tracked_ips: 10000,
255        };
256
257        // Apply CORS settings (restrictive by default)
258        security.cors = CorsConfig {
259            allow_any: self.security.cors.allow_any,
260        };
261
262        // Resolve fine-grained token scoping (preset + capabilities).
263        if let Some(capabilities) = resolve_capabilities(
264            self.security.auth.preset.as_deref(),
265            &self.security.auth.capabilities,
266        )? {
267            security = security.with_capabilities(capabilities);
268        }
269
270        // Add API keys
271        for key in &self.security.auth.api_keys {
272            security = security.with_api_key(key);
273        }
274
275        let mut server_config = ServerConfig::new(host.to_string(), self.server.port);
276        server_config = server_config.with_security(security);
277
278        if !self.server.graceful_shutdown {
279            server_config = server_config.without_graceful_shutdown();
280        }
281
282        Ok(server_config)
283    }
284
285    /// Get the log level filter string.
286    pub fn log_filter(&self) -> &str {
287        &self.logging.level
288    }
289}
290
291/// Resolve a `preset` name + explicit `capabilities` list into a capability set.
292///
293/// Returns `Ok(None)` when neither is given (full-control default). The preset
294/// (if any) forms the base set and the explicit capabilities are unioned on top.
295/// An unknown preset name is an error.
296fn resolve_capabilities(
297    preset: Option<&str>,
298    capabilities: &[String],
299) -> Result<Option<CapabilitySet>, ConfigError> {
300    if preset.is_none() && capabilities.is_empty() {
301        return Ok(None); // Full-control (legacy-compatible) default.
302    }
303
304    let mut set = match preset {
305        Some(name) => crate::security::preset(name)
306            .ok_or_else(|| ConfigError::InvalidPreset(name.to_string()))?,
307        None => CapabilitySet::new(),
308    };
309    for capability in capabilities {
310        set.insert(capability.clone());
311    }
312    Ok(Some(set))
313}
314
315/// Configuration errors.
316#[derive(Debug)]
317pub enum ConfigError {
318    /// IO error reading config file.
319    Io(std::io::Error),
320    /// JSON parsing error.
321    Json(serde_json::Error),
322    /// Invalid host address.
323    InvalidHost(String),
324    /// Unknown role preset name.
325    InvalidPreset(String),
326}
327
328impl std::fmt::Display for ConfigError {
329    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
330        match self {
331            Self::Io(e) => write!(f, "failed to read config file: {}", e),
332            Self::Json(e) => write!(f, "failed to parse config file: {}", e),
333            Self::InvalidHost(host) => write!(f, "invalid host address: {}", host),
334            Self::InvalidPreset(name) => write!(
335                f,
336                "unknown role preset: '{}' (expected operator, read-only, or full-control)",
337                name
338            ),
339        }
340    }
341}
342
343impl std::error::Error for ConfigError {}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use std::io::Write;
349    use tempfile::NamedTempFile;
350
351    #[test]
352    fn test_default_config() {
353        let config = Config::default();
354        assert_eq!(config.server.host, "127.0.0.1");
355        assert_eq!(config.server.port, 3000);
356        assert!(!config.security.auth.enabled);
357        assert!(config.security.rate_limit.enabled);
358    }
359
360    #[test]
361    fn test_config_from_json() {
362        let json = r#"{
363            "server": {
364                "host": "0.0.0.0",
365                "port": 8080
366            },
367            "security": {
368                "auth": {
369                    "enabled": true,
370                    "api_keys": ["key1", "key2"]
371                }
372            }
373        }"#;
374
375        let mut file = NamedTempFile::new().unwrap();
376        file.write_all(json.as_bytes()).unwrap();
377
378        let config = Config::from_file(file.path()).unwrap();
379        assert_eq!(config.server.host, "0.0.0.0");
380        assert_eq!(config.server.port, 8080);
381        assert!(config.security.auth.enabled);
382        assert_eq!(config.security.auth.api_keys.len(), 2);
383    }
384
385    #[test]
386    fn test_config_partial_json() {
387        let json = r#"{
388            "server": {
389                "port": 9000
390            }
391        }"#;
392
393        let mut file = NamedTempFile::new().unwrap();
394        file.write_all(json.as_bytes()).unwrap();
395
396        let config = Config::from_file(file.path()).unwrap();
397        assert_eq!(config.server.host, "127.0.0.1"); // Default
398        assert_eq!(config.server.port, 9000);
399    }
400
401    #[test]
402    fn test_apply_args() {
403        let mut config = Config::default();
404        let args = Args {
405            host: "192.168.1.1".parse().unwrap(),
406            port: 5000,
407            api_key: Some("test-key".to_string()),
408            no_rate_limit: true,
409            ..Args::default()
410        };
411
412        config.apply_args(&args);
413
414        assert_eq!(config.server.host, "192.168.1.1");
415        assert_eq!(config.server.port, 5000);
416        assert!(config.security.auth.enabled);
417        assert!(config
418            .security
419            .auth
420            .api_keys
421            .contains(&"test-key".to_string()));
422        assert!(!config.security.rate_limit.enabled);
423    }
424
425    #[test]
426    fn test_apply_no_auth() {
427        let mut config = Config::default();
428        config.security.auth.enabled = true;
429
430        let args = Args {
431            no_auth: true,
432            ..Args::default()
433        };
434
435        config.apply_args(&args);
436        assert!(!config.security.auth.enabled);
437    }
438
439    #[test]
440    fn test_apply_require_auth() {
441        let mut config = Config::default();
442        assert!(!config.security.auth.enabled); // disabled by default
443
444        config.apply_args(&Args {
445            require_auth: true,
446            ..Args::default()
447        });
448        assert!(config.security.auth.enabled);
449    }
450
451    #[test]
452    fn test_no_auth_overrides_require_auth() {
453        let mut config = Config::default();
454
455        // Contradictory flags: explicit --no-auth wins.
456        config.apply_args(&Args {
457            require_auth: true,
458            no_auth: true,
459            ..Args::default()
460        });
461        assert!(!config.security.auth.enabled);
462    }
463
464    #[test]
465    fn test_to_server_config() {
466        let config = Config::default();
467        let server_config = config.to_server_config().unwrap();
468
469        assert_eq!(server_config.host, "127.0.0.1");
470        assert_eq!(server_config.port, 3000);
471    }
472
473    #[test]
474    fn test_apply_args_capabilities_and_preset() {
475        let mut config = Config::default();
476        config.apply_args(&Args {
477            capabilities: vec!["exec".to_string(), "session.read".to_string()],
478            preset: Some("operator".to_string()),
479            ..Args::default()
480        });
481        assert_eq!(
482            config.security.auth.capabilities,
483            vec!["exec", "session.read"]
484        );
485        assert_eq!(config.security.auth.preset, Some("operator".to_string()));
486    }
487
488    #[test]
489    fn test_scope_implies_auth_on() {
490        // Specifying a scope (preset or capabilities) with no --api-key/--require-auth
491        // still turns auth on, so the server does not start open with the scope ignored.
492        let mut by_preset = Config::default();
493        by_preset.apply_args(&Args {
494            preset: Some("read-only".to_string()),
495            ..Args::default()
496        });
497        assert!(by_preset.security.auth.enabled);
498
499        let mut by_caps = Config::default();
500        by_caps.apply_args(&Args {
501            capabilities: vec!["session.read".to_string()],
502            ..Args::default()
503        });
504        assert!(by_caps.security.auth.enabled);
505    }
506
507    #[test]
508    fn test_no_auth_overrides_scope_implied_auth() {
509        // Explicit --no-auth wins even when a scope is given.
510        let mut config = Config::default();
511        config.apply_args(&Args {
512            preset: Some("read-only".to_string()),
513            no_auth: true,
514            ..Args::default()
515        });
516        assert!(!config.security.auth.enabled);
517    }
518
519    #[test]
520    fn test_config_from_json_with_capabilities_and_preset() {
521        // The new AuthSection fields deserialize from a config file and flow
522        // through to a scoped SecurityConfig.
523        let json = r#"{
524            "security": {
525                "auth": {
526                    "enabled": true,
527                    "api_keys": ["scoped"],
528                    "preset": "read-only",
529                    "capabilities": ["exec"]
530                }
531            }
532        }"#;
533        let mut file = NamedTempFile::new().unwrap();
534        file.write_all(json.as_bytes()).unwrap();
535
536        let config = Config::from_file(file.path()).unwrap();
537        assert_eq!(config.security.auth.preset, Some("read-only".to_string()));
538        assert_eq!(config.security.auth.capabilities, vec!["exec"]);
539
540        let server_config = config.to_server_config().unwrap();
541        let caps = server_config
542            .security
543            .capabilities
544            .expect("capabilities scoped from file");
545        assert!(caps.satisfies("session.read")); // from read-only preset
546        assert!(caps.satisfies("exec")); // unioned explicit capability
547        assert!(!caps.satisfies("session.manage"));
548    }
549
550    #[test]
551    fn test_resolve_capabilities_none_by_default() {
552        // No preset, no capabilities -> full-control (None).
553        assert!(resolve_capabilities(None, &[]).unwrap().is_none());
554    }
555
556    #[test]
557    fn test_resolve_capabilities_preset_plus_extra() {
558        // read-only preset unioned with an explicit `exec`.
559        let set = resolve_capabilities(Some("read-only"), &["exec".to_string()])
560            .unwrap()
561            .unwrap();
562        assert!(set.satisfies("session.read"));
563        assert!(set.satisfies("exec"));
564        assert!(!set.satisfies("session.manage"));
565    }
566
567    #[test]
568    fn test_resolve_capabilities_invalid_preset_errors() {
569        let err = resolve_capabilities(Some("superuser"), &[]);
570        assert!(matches!(err, Err(ConfigError::InvalidPreset(_))));
571    }
572
573    #[test]
574    fn test_to_server_config_scopes_capabilities() {
575        let mut config = Config::default();
576        config.security.auth.enabled = true;
577        config.security.auth.api_keys = vec!["scoped".to_string()];
578        config.security.auth.preset = Some("read-only".to_string());
579
580        let server_config = config.to_server_config().unwrap();
581        let caps = server_config
582            .security
583            .capabilities
584            .expect("capabilities scoped");
585        assert!(caps.satisfies("session.read"));
586        assert!(!caps.satisfies("exec"));
587    }
588
589    #[test]
590    fn test_to_server_config_invalid_preset_errors() {
591        let mut config = Config::default();
592        config.security.auth.preset = Some("root".to_string());
593        assert!(matches!(
594            config.to_server_config(),
595            Err(ConfigError::InvalidPreset(_))
596        ));
597    }
598
599    #[test]
600    fn test_invalid_host() {
601        let mut config = Config::default();
602        config.server.host = "not-an-ip".to_string();
603
604        let result = config.to_server_config();
605        assert!(result.is_err());
606    }
607
608    #[test]
609    fn test_config_serialization() {
610        let config = Config::default();
611        let json = serde_json::to_string_pretty(&config).unwrap();
612        assert!(json.contains("\"host\""));
613        assert!(json.contains("\"port\""));
614    }
615}