Skip to main content

bridge/
config.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::PathBuf;
5use std::sync::OnceLock;
6
7/// Global custom config directory (set via --config-dir)
8static CUSTOM_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
9
10/// Set a custom config directory (call before any config operations)
11pub fn set_config_dir(path: PathBuf) {
12    CUSTOM_CONFIG_DIR.set(path).ok();
13}
14
15/// Configuration for the ACP-Cloudflare bridge
16#[derive(Debug, Serialize, Deserialize, Clone)]
17pub struct BridgeConfig {
18    pub hostname: String,
19    pub tunnel_id: String,
20    pub tunnel_secret: String,
21    /// Cloudflare account ID (needed to write cloudflared credentials file)
22    #[serde(default)]
23    pub account_id: String,
24    pub client_id: String,
25    pub client_secret: String,
26    pub domain: String,
27    pub subdomain: String,
28    /// Authentication token for WebSocket connections (generated on first run)
29    #[serde(default)]
30    pub auth_token: String,
31    /// TLS certificate fingerprint (SHA256, hex encoded with colons)
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub cert_fingerprint: Option<String>,
34    /// Unix timestamp (seconds) when the Cloudflare service token was last issued.
35    /// Used to detect upcoming expiry (token duration: 1 year = 8760h).
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub service_token_issued_at: Option<i64>,
38    /// Cloudflare API token — stored so auto-rotation works without re-prompting.
39    /// Stored with 0600 permissions alongside other secrets.
40    #[serde(default, skip_serializing_if = "String::is_empty")]
41    pub api_token: String,
42}
43
44impl BridgeConfig {
45    /// Get the default configuration file path
46    pub fn config_path() -> PathBuf {
47        Self::config_dir().join("config.json")
48    }
49    
50    /// Get the configuration directory path
51    pub fn config_dir() -> PathBuf {
52        // Use custom config dir if set, otherwise use system default
53        let config_dir_path = if let Some(custom_dir) = CUSTOM_CONFIG_DIR.get() {
54            custom_dir.clone()
55        } else {
56            let config_dir = directories::ProjectDirs::from("com", "aptove", "bridge")
57                .expect("Failed to determine config directory");
58            config_dir.config_dir().to_path_buf()
59        };
60        
61        fs::create_dir_all(&config_dir_path).ok();
62        
63        config_dir_path
64    }
65
66    /// Save configuration to disk with secure permissions
67    pub fn save(&self) -> Result<()> {
68        let config_path = Self::config_path();
69        let json = serde_json::to_string_pretty(self)
70            .context("Failed to serialize configuration")?;
71        
72        fs::write(&config_path, &json)
73            .context(format!("Failed to write configuration to {:?}", config_path))?;
74        
75        // Set restrictive file permissions (Unix only)
76        #[cfg(unix)]
77        {
78            use std::os::unix::fs::PermissionsExt;
79            let mut perms = fs::metadata(&config_path)?.permissions();
80            perms.set_mode(0o600); // rw-------
81            fs::set_permissions(&config_path, perms)?;
82        }
83        
84        Ok(())
85    }
86
87    /// Generate a random authentication token
88    pub fn generate_auth_token() -> String {
89        use base64::{engine::general_purpose, Engine as _};
90        let random_bytes: Vec<u8> = (0..32).map(|_| rand::random::<u8>()).collect();
91        general_purpose::URL_SAFE_NO_PAD.encode(random_bytes)
92    }
93
94    /// Ensure auth_token is populated, generating one if needed
95    pub fn ensure_auth_token(&mut self) {
96        if self.auth_token.is_empty() {
97            self.auth_token = Self::generate_auth_token();
98        }
99    }
100
101    /// Service token lifetime: 1 year in seconds
102    const SERVICE_TOKEN_LIFETIME_SECS: i64 = 365 * 24 * 3600;
103    /// Rotate when fewer than 30 days remain
104    const SERVICE_TOKEN_ROTATE_THRESHOLD_SECS: i64 = 30 * 24 * 3600;
105
106    /// Returns true if the service token is expired or will expire within 30 days.
107    pub fn service_token_needs_rotation(&self) -> bool {
108        let issued_at = match self.service_token_issued_at {
109            Some(ts) => ts,
110            // No timestamp recorded → assume old/unknown, rotate to be safe
111            None => return !self.client_id.is_empty(),
112        };
113        let now = std::time::SystemTime::now()
114            .duration_since(std::time::UNIX_EPOCH)
115            .map(|d| d.as_secs() as i64)
116            .unwrap_or(0);
117        let age = now - issued_at;
118        age >= Self::SERVICE_TOKEN_LIFETIME_SECS - Self::SERVICE_TOKEN_ROTATE_THRESHOLD_SECS
119    }
120
121    /// Record now as the service token issuance time.
122    pub fn stamp_service_token_issued(&mut self) {
123        let now = std::time::SystemTime::now()
124            .duration_since(std::time::UNIX_EPOCH)
125            .map(|d| d.as_secs() as i64)
126            .unwrap_or(0);
127        self.service_token_issued_at = Some(now);
128    }
129
130    /// Load configuration from disk
131    pub fn load() -> Result<Self> {
132        let config_path = Self::config_path();
133        let json = fs::read_to_string(&config_path)
134            .context(format!("Failed to read configuration from {:?}", config_path))?;
135        
136        let config: Self = serde_json::from_str(&json)
137            .context("Failed to parse configuration file")?;
138        
139        Ok(config)
140    }
141
142    /// Get connection info as JSON for QR code
143    pub fn to_connection_json(&self) -> Result<String> {
144        use serde_json::{Map, Value};
145
146        let mut map = Map::new();
147        map.insert("url".to_string(), Value::String(self.hostname.clone()));
148        map.insert("protocol".to_string(), Value::String("acp".to_string()));
149        map.insert("version".to_string(), Value::String("1.0".to_string()));
150
151        if !self.client_id.is_empty() {
152            map.insert("clientId".to_string(), Value::String(self.client_id.clone()));
153        }
154
155        if !self.client_secret.is_empty() {
156            map.insert("clientSecret".to_string(), Value::String(self.client_secret.clone()));
157        }
158
159        // Include auth token for WebSocket authentication
160        if !self.auth_token.is_empty() {
161            map.insert("authToken".to_string(), Value::String(self.auth_token.clone()));
162        }
163        
164        // Include TLS certificate fingerprint for pinning
165        if let Some(ref fingerprint) = self.cert_fingerprint {
166            map.insert("certFingerprint".to_string(), Value::String(fingerprint.clone()));
167        }
168
169        serde_json::to_string(&Value::Object(map))
170            .context("Failed to serialize connection info")
171    }
172}