use config::Config;
use serde::Deserialize;
use std::collections::HashMap;
use std::env;
use std::sync::OnceLock;
use crate::errors::{LicenseError, LicenseResult};
use crate::tiers::TierConfig;
static CONFIG: OnceLock<TalosConfig> = OnceLock::new();
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct TalosConfig {
pub server: ServerConfig,
pub license: LicenseConfig,
pub database: DatabaseConfig,
pub logging: LoggingConfig,
pub auth: AuthConfig,
pub rate_limit: RateLimitConfig,
pub admin: AdminConfig,
pub tiers: HashMap<String, TierConfig>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
pub heartbeat_interval: u64,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: "127.0.0.1".to_string(),
port: 8080,
heartbeat_interval: 60,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct LicenseConfig {
pub key_prefix: String,
pub key_segments: u8,
pub key_segment_length: u8,
}
impl Default for LicenseConfig {
fn default() -> Self {
Self {
key_prefix: "LIC".to_string(),
key_segments: 4,
key_segment_length: 4,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct DatabaseConfig {
pub db_type: String,
pub sqlite_url: String,
pub postgres_url: String,
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
db_type: "sqlite".to_string(),
sqlite_url: "sqlite://talos.db".to_string(),
postgres_url: "postgres://localhost/talos".to_string(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct LoggingConfig {
pub enabled: bool,
pub level: String,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
enabled: false,
level: "info".to_string(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct AuthConfig {
pub enabled: bool,
pub jwt_secret: String,
pub jwt_issuer: String,
pub jwt_audience: String,
pub token_expiration_secs: u64,
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
enabled: false,
jwt_secret: String::new(),
jwt_issuer: "talos".to_string(),
jwt_audience: "talos-api".to_string(),
token_expiration_secs: 3600,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct RateLimitConfig {
pub enabled: bool,
pub validate_rpm: u32,
pub heartbeat_rpm: u32,
pub bind_rpm: u32,
pub burst_size: u32,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
enabled: true,
validate_rpm: 100,
heartbeat_rpm: 60,
bind_rpm: 10,
burst_size: 5,
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct AdminConfig {
pub ip_whitelist: Vec<String>,
pub audit_logging: bool,
}
impl TalosConfig {
fn load() -> LicenseResult<Self> {
let builder = Config::builder()
.set_default("server.host", "127.0.0.1")
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_default("server.port", 8080)
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_default("server.heartbeat_interval", 60)
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_default("license.key_prefix", "LIC")
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_default("license.key_segments", 4)
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_default("license.key_segment_length", 4)
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_default("database.db_type", "sqlite")
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_default("database.sqlite_url", "sqlite://talos.db")
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_default("database.postgres_url", "postgres://localhost/talos")
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_default("logging.enabled", false)
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_default("logging.level", "info")
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_default("auth.enabled", false)
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_default("auth.jwt_secret", "")
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_default("auth.jwt_issuer", "talos")
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_default("auth.jwt_audience", "talos-api")
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_default("auth.token_expiration_secs", 3600)
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_default("rate_limit.enabled", true)
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_default("rate_limit.validate_rpm", 100)
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_default("rate_limit.heartbeat_rpm", 60)
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_default("rate_limit.bind_rpm", 10)
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_default("rate_limit.burst_size", 5)
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_default("admin.ip_whitelist", Vec::<String>::new())
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_default("admin.audit_logging", false)
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.add_source(config::File::with_name("config").required(false))
.set_override_option("server.host", env::var("TALOS_SERVER_HOST").ok())
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_override_option(
"server.port",
env::var("TALOS_SERVER_PORT")
.ok()
.and_then(|v| v.parse::<i64>().ok()),
)
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_override_option(
"server.heartbeat_interval",
env::var("TALOS_HEARTBEAT_INTERVAL")
.ok()
.and_then(|v| v.parse::<i64>().ok()),
)
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_override_option(
"license.key_prefix",
env::var("TALOS_LICENSE_KEY_PREFIX").ok(),
)
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_override_option("database.db_type", env::var("TALOS_DATABASE_TYPE").ok())
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_override_option(
"database.sqlite_url",
env::var("TALOS_DATABASE_URL")
.ok()
.filter(|url| url.starts_with("sqlite")),
)
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_override_option(
"database.postgres_url",
env::var("TALOS_DATABASE_URL")
.ok()
.filter(|url| url.starts_with("postgres")),
)
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_override_option(
"logging.enabled",
env::var("TALOS_LOGGING_ENABLED")
.ok()
.and_then(|v| v.parse::<bool>().ok()),
)
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_override_option("logging.level", env::var("TALOS_LOG_LEVEL").ok())
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_override_option(
"auth.enabled",
env::var("TALOS_AUTH_ENABLED")
.ok()
.and_then(|v| v.parse::<bool>().ok()),
)
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_override_option("auth.jwt_secret", env::var("TALOS_JWT_SECRET").ok())
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_override_option("auth.jwt_issuer", env::var("TALOS_JWT_ISSUER").ok())
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_override_option("auth.jwt_audience", env::var("TALOS_JWT_AUDIENCE").ok())
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_override_option(
"auth.token_expiration_secs",
env::var("TALOS_TOKEN_EXPIRATION_SECS")
.ok()
.and_then(|v| v.parse::<i64>().ok()),
)
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_override_option(
"admin.ip_whitelist",
env::var("TALOS_ADMIN_IP_WHITELIST").ok().map(|v| {
if v.is_empty() {
Vec::<String>::new()
} else {
v.split(',').map(|s| s.trim().to_string()).collect()
}
}),
)
.map_err(|e| LicenseError::ConfigError(e.to_string()))?
.set_override_option(
"admin.audit_logging",
env::var("TALOS_ADMIN_AUDIT_LOGGING")
.ok()
.and_then(|v| v.parse::<bool>().ok()),
)
.map_err(|e| LicenseError::ConfigError(e.to_string()))?;
let settings = builder
.build()
.map_err(|e| LicenseError::ConfigError(format!("failed to build config: {e}")))?;
settings
.try_deserialize()
.map_err(|e| LicenseError::ConfigError(format!("failed to deserialize config: {e}")))
}
pub fn validate(&self) -> LicenseResult<()> {
if self.server.port == 0 {
return Err(LicenseError::ConfigError(
"server.port must be greater than 0".to_string(),
));
}
match self.database.db_type.as_str() {
"sqlite" | "postgres" => {}
other => {
return Err(LicenseError::ConfigError(format!(
"database.db_type must be 'sqlite' or 'postgres', got '{other}'"
)));
}
}
if self.license.key_prefix.is_empty() {
return Err(LicenseError::ConfigError(
"license.key_prefix cannot be empty".to_string(),
));
}
if self.license.key_segments == 0 {
return Err(LicenseError::ConfigError(
"license.key_segments must be greater than 0".to_string(),
));
}
if self.license.key_segment_length == 0 {
return Err(LicenseError::ConfigError(
"license.key_segment_length must be greater than 0".to_string(),
));
}
match self.logging.level.to_lowercase().as_str() {
"trace" | "debug" | "info" | "warn" | "error" => {}
other => {
return Err(LicenseError::ConfigError(format!(
"logging.level must be one of: trace, debug, info, warn, error. Got '{other}'"
)));
}
}
if self.auth.enabled && self.auth.jwt_secret.is_empty() {
return Err(LicenseError::ConfigError(
"auth.jwt_secret is required when auth.enabled is true".to_string(),
));
}
Ok(())
}
}
pub fn get_config() -> LicenseResult<&'static TalosConfig> {
if let Some(config) = CONFIG.get() {
return Ok(config);
}
let config = TalosConfig::load()?;
config.validate()?;
let _ = CONFIG.set(config.clone());
Ok(CONFIG.get().expect("config was just set"))
}
pub fn init_config() -> LicenseResult<&'static TalosConfig> {
get_config()
}
use crate::client::license::License;
pub fn get_server_url(license: &License) -> String {
if let Ok(url) = env::var("TALOS_SERVER_URL").or_else(|_| env::var("SERVER_URL")) {
return url;
}
if let Ok(config) = get_config() {
return format!("http://{}:{}", config.server.host, config.server.port);
}
license.server_url.clone()
}
pub fn get_heartbeat_interval() -> u64 {
get_config()
.map(|c| c.server.heartbeat_interval)
.unwrap_or(60)
}
pub fn is_logging_enabled() -> bool {
get_config().map(|c| c.logging.enabled).unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
fn default_config() -> TalosConfig {
TalosConfig::default()
}
#[test]
fn default_config_is_valid() {
let config = default_config();
assert!(config.validate().is_ok());
}
#[test]
fn validates_port_not_zero() {
let mut config = default_config();
config.server.port = 0;
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("port"));
}
#[test]
fn validates_database_type() {
let mut config = default_config();
config.database.db_type = "invalid".to_string();
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("db_type"));
}
#[test]
fn validates_log_level() {
let mut config = default_config();
config.logging.level = "invalid".to_string();
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("logging.level"));
}
#[test]
fn validates_jwt_secret_required_when_auth_enabled() {
let mut config = default_config();
config.auth.enabled = true;
config.auth.jwt_secret = String::new(); let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("jwt_secret"));
}
#[test]
fn validates_jwt_secret_not_required_when_auth_disabled() {
let mut config = default_config();
config.auth.enabled = false;
config.auth.jwt_secret = String::new(); assert!(config.validate().is_ok());
}
#[test]
fn validates_license_key_prefix_not_empty() {
let mut config = default_config();
config.license.key_prefix = String::new();
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("key_prefix"));
}
#[test]
fn validates_license_key_segments_not_zero() {
let mut config = default_config();
config.license.key_segments = 0;
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("key_segments"));
}
#[test]
fn validates_license_key_segment_length_not_zero() {
let mut config = default_config();
config.license.key_segment_length = 0;
let result = config.validate();
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("key_segment_length"));
}
}