1use std::env;
9use std::path::Path;
10
11use serde::Deserialize;
12use siphon_secrets::{SecretResolver, SecretUri};
13
14const ENV_PREFIX: &str = "SIPHON";
16
17#[derive(Debug, Deserialize, Default)]
19#[serde(default)]
20pub struct ServerConfig {
21 pub control_port: Option<u16>,
23
24 pub http_port: Option<u16>,
26
27 pub base_domain: Option<String>,
29
30 #[serde(alias = "cert_path")]
32 pub cert: Option<String>,
33
34 #[serde(alias = "key_path")]
36 pub key: Option<String>,
37
38 #[serde(alias = "ca_cert_path")]
40 pub ca_cert: Option<String>,
41
42 pub cloudflare: Option<CloudflareConfig>,
44
45 pub tcp_port_range: Option<(u16, u16)>,
47
48 pub http_cert: Option<String>,
50
51 pub http_key: Option<String>,
53}
54
55#[derive(Debug, Deserialize, Default)]
57#[serde(default)]
58pub struct CloudflareConfig {
59 pub api_token: Option<String>,
61
62 pub zone_id: Option<String>,
64
65 pub server_ip: Option<String>,
67
68 pub server_cname: Option<String>,
70
71 pub auto_origin_ca: Option<bool>,
75}
76
77#[derive(Debug)]
79pub struct ResolvedServerConfig {
80 pub control_port: u16,
81 pub http_port: u16,
82 pub base_domain: String,
83 pub cert_pem: String,
84 pub key_pem: String,
85 pub ca_cert_pem: String,
86 pub cloudflare: ResolvedCloudflareConfig,
87 pub tcp_port_range: (u16, u16),
88 pub http_cert_pem: Option<String>,
90 pub http_key_pem: Option<String>,
92}
93
94#[derive(Debug, Clone)]
96pub enum DnsTarget {
97 Ip(String),
99 Cname(String),
101}
102
103#[derive(Debug)]
105pub struct ResolvedCloudflareConfig {
106 pub api_token: String,
107 pub zone_id: String,
108 pub dns_target: DnsTarget,
109 pub auto_origin_ca: bool,
111}
112
113fn get_env(name: &str) -> Option<String> {
115 env::var(format!("{}_{}", ENV_PREFIX, name)).ok()
116}
117
118fn get_env_u16(name: &str) -> Option<u16> {
120 get_env(name).and_then(|v| v.parse().ok())
121}
122
123fn get_env_bool(name: &str) -> Option<bool> {
125 get_env(name).map(|v| matches!(v.to_lowercase().as_str(), "true" | "1" | "yes"))
126}
127
128fn detect_public_ip() -> anyhow::Result<String> {
130 if let Some(ip) = detect_ip_cloudflare() {
132 tracing::info!("Detected public IP: {}", ip);
133 return Ok(ip);
134 }
135
136 let services = [
138 "https://api.ipify.org",
139 "https://ifconfig.me/ip",
140 "https://icanhazip.com",
141 ];
142
143 for service in services {
144 match ureq::get(service).call() {
145 Ok(mut response) => {
146 if let Ok(ip) = response.body_mut().read_to_string() {
147 let ip = ip.trim().to_string();
148 if !ip.is_empty() {
149 tracing::info!("Detected public IP: {}", ip);
150 return Ok(ip);
151 }
152 }
153 }
154 Err(e) => {
155 tracing::debug!("Failed to get IP from {}: {}", service, e);
156 }
157 }
158 }
159
160 anyhow::bail!(
161 "Could not auto-detect server IP. Set SIPHON_SERVER_IP or cloudflare.server_ip in config"
162 )
163}
164
165fn detect_ip_cloudflare() -> Option<String> {
167 match ureq::get("https://cloudflare.com/cdn-cgi/trace").call() {
168 Ok(mut response) => {
169 if let Ok(body) = response.body_mut().read_to_string() {
170 for line in body.lines() {
172 if let Some(ip) = line.strip_prefix("ip=") {
173 return Some(ip.to_string());
174 }
175 }
176 }
177 None
178 }
179 Err(e) => {
180 tracing::debug!("Failed to get IP from Cloudflare trace: {}", e);
181 None
182 }
183 }
184}
185
186impl ServerConfig {
187 pub fn load(path: &str) -> Self {
189 if Path::new(path).exists() {
190 match std::fs::read_to_string(path) {
191 Ok(content) => match toml::from_str(&content) {
192 Ok(config) => {
193 tracing::info!("Loaded config from {}", path);
194 return config;
195 }
196 Err(e) => {
197 tracing::warn!("Failed to parse {}: {}", path, e);
198 }
199 },
200 Err(e) => {
201 tracing::warn!("Failed to read {}: {}", path, e);
202 }
203 }
204 }
205 Self::default()
206 }
207
208 pub fn resolve(self) -> anyhow::Result<ResolvedServerConfig> {
210 let resolver = SecretResolver::new();
211
212 let control_port = get_env_u16("CONTROL_PORT")
214 .or(self.control_port)
215 .unwrap_or(4443);
216
217 let http_port = get_env_u16("HTTP_PORT").or(self.http_port).unwrap_or(8080);
219
220 let base_domain = get_env("BASE_DOMAIN").or(self.base_domain).ok_or_else(|| {
222 anyhow::anyhow!("Base domain required. Set SIPHON_BASE_DOMAIN or base_domain in config")
223 })?;
224
225 let cert_source = get_env("CERT").or(self.cert).ok_or_else(|| {
227 anyhow::anyhow!("Certificate required. Set SIPHON_CERT or cert in config")
228 })?;
229
230 let key_source = get_env("KEY").or(self.key).ok_or_else(|| {
232 anyhow::anyhow!("Private key required. Set SIPHON_KEY or key in config")
233 })?;
234
235 let ca_cert_source = get_env("CA_CERT").or(self.ca_cert).ok_or_else(|| {
237 anyhow::anyhow!("CA certificate required. Set SIPHON_CA_CERT or ca_cert in config")
238 })?;
239
240 let cf_config = self.cloudflare.unwrap_or_default();
242 let cf_api_token_source = get_env("CLOUDFLARE_API_TOKEN")
243 .or(cf_config.api_token)
244 .ok_or_else(|| anyhow::anyhow!(
245 "Cloudflare API token required. Set SIPHON_CLOUDFLARE_API_TOKEN or cloudflare.api_token in config"
246 ))?;
247
248 let cf_zone_id = get_env("CLOUDFLARE_ZONE_ID")
250 .or(cf_config.zone_id)
251 .ok_or_else(|| anyhow::anyhow!(
252 "Cloudflare zone ID required. Set SIPHON_CLOUDFLARE_ZONE_ID or cloudflare.zone_id in config"
253 ))?;
254
255 let cf_server_ip = get_env("SERVER_IP").or(cf_config.server_ip);
257 let cf_server_cname = get_env("SERVER_CNAME").or(cf_config.server_cname);
258
259 let dns_target = match (cf_server_ip, cf_server_cname) {
260 (Some(_), Some(_)) => {
261 anyhow::bail!(
262 "Cannot set both SIPHON_SERVER_IP and SIPHON_SERVER_CNAME. Use one or the other."
263 )
264 }
265 (Some(ip), None) => DnsTarget::Ip(ip),
266 (None, Some(cname)) => DnsTarget::Cname(cname),
267 (None, None) => {
268 tracing::info!("Server IP/CNAME not configured, auto-detecting IP...");
269 DnsTarget::Ip(detect_public_ip()?)
270 }
271 };
272
273 let auto_origin_ca = get_env_bool("CLOUDFLARE_AUTO_ORIGIN_CA")
275 .or(cf_config.auto_origin_ca)
276 .unwrap_or(false);
277
278 let tcp_port_start = get_env_u16("TCP_PORT_START")
280 .or(self.tcp_port_range.map(|r| r.0))
281 .unwrap_or(30000);
282 let tcp_port_end = get_env_u16("TCP_PORT_END")
283 .or(self.tcp_port_range.map(|r| r.1))
284 .unwrap_or(40000);
285
286 tracing::info!("Resolving secrets...");
288
289 let cert_uri: SecretUri = cert_source
290 .parse()
291 .map_err(|e| anyhow::anyhow!("Invalid certificate source: {}", e))?;
292 let key_uri: SecretUri = key_source
293 .parse()
294 .map_err(|e| anyhow::anyhow!("Invalid key source: {}", e))?;
295 let ca_cert_uri: SecretUri = ca_cert_source
296 .parse()
297 .map_err(|e| anyhow::anyhow!("Invalid CA certificate source: {}", e))?;
298 let api_token_uri: SecretUri = cf_api_token_source
299 .parse()
300 .map_err(|e| anyhow::anyhow!("Invalid Cloudflare API token source: {}", e))?;
301
302 let cert_pem = resolver
303 .resolve_trimmed(&cert_uri)
304 .map_err(|e| anyhow::anyhow!("Failed to resolve certificate: {}", e))?;
305 let key_pem = resolver
306 .resolve_trimmed(&key_uri)
307 .map_err(|e| anyhow::anyhow!("Failed to resolve private key: {}", e))?;
308 let ca_cert_pem = resolver
309 .resolve_trimmed(&ca_cert_uri)
310 .map_err(|e| anyhow::anyhow!("Failed to resolve CA certificate: {}", e))?;
311 let api_token = resolver
312 .resolve_trimmed(&api_token_uri)
313 .map_err(|e| anyhow::anyhow!("Failed to resolve Cloudflare API token: {}", e))?;
314
315 let http_cert_source = get_env("HTTP_CERT").or(self.http_cert);
317 let http_key_source = get_env("HTTP_KEY").or(self.http_key);
318
319 let (http_cert_pem, http_key_pem) = match (http_cert_source, http_key_source) {
320 (Some(cert_src), Some(key_src)) => {
321 let cert_uri: SecretUri = cert_src
322 .parse()
323 .map_err(|e| anyhow::anyhow!("Invalid HTTP certificate source: {}", e))?;
324 let key_uri: SecretUri = key_src
325 .parse()
326 .map_err(|e| anyhow::anyhow!("Invalid HTTP key source: {}", e))?;
327
328 let cert = resolver
329 .resolve_trimmed(&cert_uri)
330 .map_err(|e| anyhow::anyhow!("Failed to resolve HTTP certificate: {}", e))?;
331 let key = resolver
332 .resolve_trimmed(&key_uri)
333 .map_err(|e| anyhow::anyhow!("Failed to resolve HTTP key: {}", e))?;
334
335 tracing::info!("HTTP plane TLS enabled");
336 (Some(cert), Some(key))
337 }
338 (Some(_), None) => {
339 anyhow::bail!("SIPHON_HTTP_CERT is set but SIPHON_HTTP_KEY is missing")
340 }
341 (None, Some(_)) => {
342 anyhow::bail!("SIPHON_HTTP_KEY is set but SIPHON_HTTP_CERT is missing")
343 }
344 (None, None) => (None, None),
345 };
346
347 tracing::info!("All secrets resolved successfully");
348
349 Ok(ResolvedServerConfig {
350 control_port,
351 http_port,
352 base_domain,
353 cert_pem,
354 key_pem,
355 ca_cert_pem,
356 cloudflare: ResolvedCloudflareConfig {
357 api_token,
358 zone_id: cf_zone_id,
359 dns_target,
360 auto_origin_ca,
361 },
362 tcp_port_range: (tcp_port_start, tcp_port_end),
363 http_cert_pem,
364 http_key_pem,
365 })
366 }
367
368 pub fn load_and_resolve(path: &str) -> anyhow::Result<ResolvedServerConfig> {
370 let config = Self::load(path);
371 config.resolve()
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378
379 #[test]
380 fn test_env_prefix() {
381 assert_eq!(ENV_PREFIX, "SIPHON");
382 }
383
384 #[test]
385 fn test_default_config() {
386 let config = ServerConfig::default();
387 assert!(config.control_port.is_none());
388 assert!(config.http_port.is_none());
389 assert!(config.base_domain.is_none());
390 }
391}