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; #[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 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 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 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 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 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}