siphon_server/
config.rs

1//! Server configuration with environment variable priority
2//!
3//! Configuration is resolved in this order (first found wins):
4//! 1. Environment variables (SIPHON_*)
5//! 2. Config file (server.toml)
6//! 3. Default values (where applicable)
7
8use std::env;
9use std::path::Path;
10
11use serde::Deserialize;
12use siphon_secrets::{SecretResolver, SecretUri};
13
14/// Environment variable prefix
15const ENV_PREFIX: &str = "SIPHON";
16
17/// Server configuration (parsed from TOML, can be overridden by env)
18#[derive(Debug, Deserialize, Default)]
19#[serde(default)]
20pub struct ServerConfig {
21    /// Port for control plane (mTLS client connections)
22    pub control_port: Option<u16>,
23
24    /// Port for HTTP data plane (traffic from Cloudflare)
25    pub http_port: Option<u16>,
26
27    /// Base domain for tunnels (e.g., "tunnel.example.com")
28    pub base_domain: Option<String>,
29
30    /// Server certificate (file path, keychain://, op://, env://, or plain PEM)
31    #[serde(alias = "cert_path")]
32    pub cert: Option<String>,
33
34    /// Server private key (file path, keychain://, op://, env://, or plain PEM)
35    #[serde(alias = "key_path")]
36    pub key: Option<String>,
37
38    /// CA certificate for client verification (file path, keychain://, op://, env://, or plain PEM)
39    #[serde(alias = "ca_cert_path")]
40    pub ca_cert: Option<String>,
41
42    /// Cloudflare configuration
43    pub cloudflare: Option<CloudflareConfig>,
44
45    /// TCP port range for TCP tunnels
46    pub tcp_port_range: Option<(u16, u16)>,
47
48    /// HTTP plane certificate for TLS (optional - enables HTTPS if set)
49    pub http_cert: Option<String>,
50
51    /// HTTP plane private key for TLS (optional - enables HTTPS if set)
52    pub http_key: Option<String>,
53}
54
55/// Cloudflare API configuration
56#[derive(Debug, Deserialize, Default)]
57#[serde(default)]
58pub struct CloudflareConfig {
59    /// API token with DNS edit permissions
60    pub api_token: Option<String>,
61
62    /// Zone ID for the domain
63    pub zone_id: Option<String>,
64
65    /// Server's public IP (for A records) - mutually exclusive with server_cname
66    pub server_ip: Option<String>,
67
68    /// Server's CNAME target (for CNAME records) - use for platforms like Railway
69    pub server_cname: Option<String>,
70
71    /// Automatically generate Origin CA certificate for Cloudflare Full (Strict) mode
72    /// When enabled, the server will request a certificate from Cloudflare's Origin CA
73    /// and use it for HTTPS on the HTTP plane. No manual certificate setup needed.
74    pub auto_origin_ca: Option<bool>,
75}
76
77/// Resolved server configuration with actual secret values
78#[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    /// HTTP plane TLS certificate (if HTTPS is enabled)
89    pub http_cert_pem: Option<String>,
90    /// HTTP plane TLS private key (if HTTPS is enabled)
91    pub http_key_pem: Option<String>,
92}
93
94/// DNS record target type
95#[derive(Debug, Clone)]
96pub enum DnsTarget {
97    /// A record pointing to an IP address
98    Ip(String),
99    /// CNAME record pointing to a hostname
100    Cname(String),
101}
102
103/// Resolved Cloudflare configuration with actual secret values
104#[derive(Debug)]
105pub struct ResolvedCloudflareConfig {
106    pub api_token: String,
107    pub zone_id: String,
108    pub dns_target: DnsTarget,
109    /// Whether to auto-generate Origin CA certificate
110    pub auto_origin_ca: bool,
111}
112
113/// Get environment variable with prefix
114fn get_env(name: &str) -> Option<String> {
115    env::var(format!("{}_{}", ENV_PREFIX, name)).ok()
116}
117
118/// Get environment variable as u16
119fn get_env_u16(name: &str) -> Option<u16> {
120    get_env(name).and_then(|v| v.parse().ok())
121}
122
123/// Get environment variable as bool
124fn get_env_bool(name: &str) -> Option<bool> {
125    get_env(name).map(|v| matches!(v.to_lowercase().as_str(), "true" | "1" | "yes"))
126}
127
128/// Auto-detect public IP address using external services
129fn detect_public_ip() -> anyhow::Result<String> {
130    // Try Cloudflare first (most reliable, returns structured data)
131    if let Some(ip) = detect_ip_cloudflare() {
132        tracing::info!("Detected public IP: {}", ip);
133        return Ok(ip);
134    }
135
136    // Fallback to simple IP echo services
137    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
165/// Detect IP using Cloudflare's trace endpoint
166fn 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                // Parse "ip=x.x.x.x" from the response
171                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    /// Load configuration from a TOML file (optional)
188    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    /// Resolve configuration from environment variables first, then config file
209    pub fn resolve(self) -> anyhow::Result<ResolvedServerConfig> {
210        let resolver = SecretResolver::new();
211
212        // Control port: ENV > config > default 4443
213        let control_port = get_env_u16("CONTROL_PORT")
214            .or(self.control_port)
215            .unwrap_or(4443);
216
217        // HTTP port: ENV > config > default 8080
218        let http_port = get_env_u16("HTTP_PORT").or(self.http_port).unwrap_or(8080);
219
220        // Base domain: ENV > config > required
221        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        // Certificate: ENV > config > required
226        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        // Key: ENV > config > required
231        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        // CA cert: ENV > config > required
236        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        // Cloudflare API token: ENV > config > required
241        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        // Cloudflare zone ID: ENV > config > required
249        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        // DNS target: CNAME or IP (mutually exclusive)
256        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        // Auto Origin CA: ENV > config > default false
274        let auto_origin_ca = get_env_bool("CLOUDFLARE_AUTO_ORIGIN_CA")
275            .or(cf_config.auto_origin_ca)
276            .unwrap_or(false);
277
278        // TCP port range: ENV > config > default 30000-40000
279        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        // Resolve secrets
287        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        // HTTP plane TLS (optional)
316        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    /// Load config file and resolve with environment variable overrides
369    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}