Skip to main content

shuttle/
config.rs

1use serde::Deserialize;
2use std::collections::HashMap;
3use std::path::PathBuf;
4
5#[derive(Debug, Clone, Deserialize)]
6pub struct HostEntry {
7    pub address: String,
8    pub user: String,
9    #[serde(default = "default_port")]
10    pub port: u16,
11    pub identity_label: String,
12    #[serde(default)]
13    pub trust_on_first_use: bool,
14}
15
16fn default_port() -> u16 {
17    22
18}
19
20const MAX_TIMEOUT_SECS: u64 = 3600;
21const MAX_OUTPUT_BYTES: usize = 16 * 1024 * 1024; // 16 MiB
22
23#[derive(Debug, Clone)]
24pub struct ShuttleConfig {
25    pub hosts_file: PathBuf,
26    pub known_hosts_file: PathBuf,
27    pub default_timeout_secs: u64,
28    pub max_output_bytes: usize,
29    pub allowed_hosts: Option<Vec<String>>,
30    pub allowed_tunnel_destinations: Option<Vec<String>>,
31    pub connection_pool_size: usize,
32    hosts: HashMap<String, HostEntry>,
33    /// Once true, hosts_file and known_hosts_file cannot be changed via RPC.
34    paths_locked: bool,
35}
36
37impl Default for ShuttleConfig {
38    fn default() -> Self {
39        let base = dirs::home_dir()
40            .unwrap_or_else(|| PathBuf::from("/tmp"))
41            .join(".omegon")
42            .join("shuttle");
43        Self {
44            hosts_file: base.join("hosts.toml"),
45            known_hosts_file: base.join("known_hosts"),
46            default_timeout_secs: 30,
47            max_output_bytes: 1_048_576,
48            allowed_hosts: None,
49            allowed_tunnel_destinations: None,
50            connection_pool_size: 4,
51            hosts: HashMap::new(),
52            paths_locked: false,
53        }
54    }
55}
56
57impl ShuttleConfig {
58    pub fn load_hosts(&mut self) -> Result<(), ConfigError> {
59        if !self.hosts_file.exists() {
60            return Err(ConfigError::HostsFileNotFound(
61                self.hosts_file.display().to_string(),
62            ));
63        }
64        let content = std::fs::read_to_string(&self.hosts_file)
65            .map_err(|e| ConfigError::Io(self.hosts_file.display().to_string(), e))?;
66        self.hosts = toml::from_str(&content)
67            .map_err(|e| ConfigError::Parse(self.hosts_file.display().to_string(), e))?;
68        // Lock file paths after first successful load — prevents RPC config
69        // from redirecting to attacker-controlled files.
70        self.paths_locked = true;
71        Ok(())
72    }
73
74    pub fn resolve_host(&self, name: &str) -> Result<&HostEntry, ConfigError> {
75        if let Some(ref allowed) = self.allowed_hosts {
76            if !allowed.iter().any(|a| a == name) {
77                return Err(ConfigError::HostNotAllowed(name.to_string()));
78            }
79        }
80        self.hosts
81            .get(name)
82            .ok_or_else(|| ConfigError::HostNotFound(name.to_string()))
83    }
84
85    pub fn host_names(&self) -> Vec<&str> {
86        let mut names: Vec<&str> = self.hosts.keys().map(|s| s.as_str()).collect();
87        names.sort();
88        names
89    }
90
91    pub fn apply_rpc_config(&mut self, config: &HashMap<String, serde_json::Value>) {
92        // File paths can only be set before first load (paths_locked = false).
93        if !self.paths_locked {
94            if let Some(v) = config.get("hosts_file").and_then(|v| v.as_str()) {
95                self.hosts_file = expand_tilde(v);
96            }
97            if let Some(v) = config.get("known_hosts_file").and_then(|v| v.as_str()) {
98                self.known_hosts_file = expand_tilde(v);
99            }
100        }
101
102        if let Some(v) = config.get("default_timeout_secs").and_then(|v| v.as_u64()) {
103            self.default_timeout_secs = v.clamp(1, MAX_TIMEOUT_SECS);
104        }
105        if let Some(v) = config.get("max_output_bytes").and_then(|v| v.as_u64()) {
106            self.max_output_bytes = (v as usize).clamp(1024, MAX_OUTPUT_BYTES);
107        }
108        if let Some(v) = config.get("connection_pool_size").and_then(|v| v.as_u64()) {
109            self.connection_pool_size = (v as usize).clamp(1, 32);
110        }
111        if let Some(v) = config.get("allowed_hosts").and_then(|v| v.as_str()) {
112            let new_hosts: Vec<String> = v
113                .split(',')
114                .map(|s| s.trim().to_string())
115                .filter(|s| !s.is_empty())
116                .collect();
117            if !new_hosts.is_empty() {
118                // Can only tighten — if already set, intersect with new list.
119                // Prevents RPC config from widening access after initial load.
120                // An empty intersection means ZERO hosts are accessible — that's
121                // correct behavior, not a fallback case.
122                self.allowed_hosts = match self.allowed_hosts.take() {
123                    Some(existing) => {
124                        let intersection: Vec<String> = existing
125                            .into_iter()
126                            .filter(|h| new_hosts.contains(h))
127                            .collect();
128                        if intersection.is_empty() {
129                            tracing::warn!(
130                                "allowed_hosts intersection is empty — no hosts accessible"
131                            );
132                        }
133                        Some(intersection)
134                    }
135                    None => Some(new_hosts),
136                };
137            }
138        }
139        if let Some(v) = config
140            .get("allowed_tunnel_destinations")
141            .and_then(|v| v.as_str())
142        {
143            let new_dests: Vec<String> = v
144                .split(',')
145                .map(|s| s.trim().to_string())
146                .filter(|s| !s.is_empty())
147                .collect();
148            if !new_dests.is_empty() {
149                // Same intersection-tightening as allowed_hosts — can only
150                // narrow the set, never widen it after initial configuration.
151                self.allowed_tunnel_destinations =
152                    match self.allowed_tunnel_destinations.take() {
153                        Some(existing) => {
154                            let intersection: Vec<String> = existing
155                                .into_iter()
156                                .filter(|d| new_dests.contains(d))
157                                .collect();
158                            if intersection.is_empty() {
159                                tracing::warn!(
160                                    "allowed_tunnel_destinations intersection is empty"
161                                );
162                            }
163                            Some(intersection)
164                        }
165                        None => Some(new_dests),
166                    };
167            }
168        }
169    }
170}
171
172fn expand_tilde(path: &str) -> PathBuf {
173    if let Some(rest) = path.strip_prefix("~/") {
174        if let Some(home) = dirs::home_dir() {
175            return home.join(rest);
176        }
177    }
178    PathBuf::from(path)
179}
180
181#[derive(Debug, thiserror::Error)]
182pub enum ConfigError {
183    #[error("hosts file not found: {0}")]
184    HostsFileNotFound(String),
185    #[error("host not found: {0}")]
186    HostNotFound(String),
187    #[error("host not in allowlist: {0}")]
188    HostNotAllowed(String),
189    #[error("failed to read {0}: {1}")]
190    Io(String, std::io::Error),
191    #[error("failed to parse {0}: {1}")]
192    Parse(String, toml::de::Error),
193}