use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::OnceLock;
static CUSTOM_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
pub fn set_config_dir(path: PathBuf) {
CUSTOM_CONFIG_DIR.set(path).ok();
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BridgeConfig {
pub hostname: String,
pub tunnel_id: String,
pub tunnel_secret: String,
#[serde(default)]
pub account_id: String,
pub client_id: String,
pub client_secret: String,
pub domain: String,
pub subdomain: String,
#[serde(default)]
pub auth_token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cert_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service_token_issued_at: Option<i64>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub api_token: String,
}
impl BridgeConfig {
pub fn config_path() -> PathBuf {
Self::config_dir().join("config.json")
}
pub fn config_dir() -> PathBuf {
let config_dir_path = if let Some(custom_dir) = CUSTOM_CONFIG_DIR.get() {
custom_dir.clone()
} else {
let config_dir = directories::ProjectDirs::from("com", "aptove", "bridge")
.expect("Failed to determine config directory");
config_dir.config_dir().to_path_buf()
};
fs::create_dir_all(&config_dir_path).ok();
config_dir_path
}
pub fn save(&self) -> Result<()> {
let config_path = Self::config_path();
let json = serde_json::to_string_pretty(self)
.context("Failed to serialize configuration")?;
fs::write(&config_path, &json)
.context(format!("Failed to write configuration to {:?}", config_path))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&config_path)?.permissions();
perms.set_mode(0o600); fs::set_permissions(&config_path, perms)?;
}
Ok(())
}
pub fn generate_auth_token() -> String {
use base64::{engine::general_purpose, Engine as _};
let random_bytes: Vec<u8> = (0..32).map(|_| rand::random::<u8>()).collect();
general_purpose::URL_SAFE_NO_PAD.encode(random_bytes)
}
pub fn ensure_auth_token(&mut self) {
if self.auth_token.is_empty() {
self.auth_token = Self::generate_auth_token();
}
}
const SERVICE_TOKEN_LIFETIME_SECS: i64 = 365 * 24 * 3600;
const SERVICE_TOKEN_ROTATE_THRESHOLD_SECS: i64 = 30 * 24 * 3600;
pub fn service_token_needs_rotation(&self) -> bool {
let issued_at = match self.service_token_issued_at {
Some(ts) => ts,
None => return !self.client_id.is_empty(),
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let age = now - issued_at;
age >= Self::SERVICE_TOKEN_LIFETIME_SECS - Self::SERVICE_TOKEN_ROTATE_THRESHOLD_SECS
}
pub fn stamp_service_token_issued(&mut self) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
self.service_token_issued_at = Some(now);
}
pub fn load() -> Result<Self> {
let config_path = Self::config_path();
let json = fs::read_to_string(&config_path)
.context(format!("Failed to read configuration from {:?}", config_path))?;
let config: Self = serde_json::from_str(&json)
.context("Failed to parse configuration file")?;
Ok(config)
}
pub fn to_connection_json(&self) -> Result<String> {
use serde_json::{Map, Value};
let mut map = Map::new();
map.insert("url".to_string(), Value::String(self.hostname.clone()));
map.insert("protocol".to_string(), Value::String("acp".to_string()));
map.insert("version".to_string(), Value::String("1.0".to_string()));
if !self.client_id.is_empty() {
map.insert("clientId".to_string(), Value::String(self.client_id.clone()));
}
if !self.client_secret.is_empty() {
map.insert("clientSecret".to_string(), Value::String(self.client_secret.clone()));
}
if !self.auth_token.is_empty() {
map.insert("authToken".to_string(), Value::String(self.auth_token.clone()));
}
if let Some(ref fingerprint) = self.cert_fingerprint {
map.insert("certFingerprint".to_string(), Value::String(fingerprint.clone()));
}
serde_json::to_string(&Value::Object(map))
.context("Failed to serialize connection info")
}
}