use std::net::SocketAddr;
use std::path::PathBuf;
use figment::{
providers::{Env, Format, Serialized, Toml},
Figment,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
pub server: HttpConfig,
pub storage: StorageConfig,
pub auth: AuthConfig,
pub acl: AclConfig,
pub log: LogConfig,
#[serde(default)]
pub curator: CuratorConfig,
#[serde(default)]
pub ratelimit: RateLimitConfig,
#[serde(default)]
pub embed: EmbedConfig,
#[serde(default)]
pub event_log: EventLogConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HttpConfig {
pub bind: SocketAddr,
pub metrics_bind: SocketAddr,
pub tls: Option<TlsConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TlsConfig {
pub cert_path: PathBuf,
pub key_path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct StorageConfig {
pub root: PathBuf,
pub vault_index_path: PathBuf,
#[doc(hidden)]
pub legacy_alias_used: bool,
}
impl StorageConfig {
pub fn legacy_alias_used(&self) -> bool {
self.legacy_alias_used
}
}
impl serde::Serialize for StorageConfig {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let mut state = s.serialize_struct("StorageConfig", 2)?;
state.serialize_field("root", &self.root)?;
state.serialize_field("vault_index_path", &self.vault_index_path)?;
state.end()
}
}
impl<'de> serde::Deserialize<'de> for StorageConfig {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
StorageConfigRaw::deserialize(d).map(StorageConfig::from)
}
}
#[derive(Debug, serde::Deserialize)]
struct StorageConfigRaw {
root: PathBuf,
#[serde(default)]
vault_index_path: Option<PathBuf>,
#[serde(default)]
db_path: Option<PathBuf>,
}
impl From<StorageConfigRaw> for StorageConfig {
fn from(raw: StorageConfigRaw) -> Self {
let default_path = raw.root.join("db/index.sqlite");
let (vault_index_path, legacy_alias_used) = match (raw.vault_index_path, raw.db_path) {
(Some(canonical), Some(_legacy)) => (canonical, true),
(Some(canonical), None) => (canonical, false),
(None, Some(legacy)) => (legacy, true),
(None, None) => (default_path, false),
};
StorageConfig {
root: raw.root,
vault_index_path,
legacy_alias_used,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthConfig {
pub jwt_public_key_path: PathBuf,
pub jwt_private_key_path: PathBuf,
pub jwt_ttl_human_secs: u64,
pub jwt_ttl_service_secs: u64,
pub revocation_store: String,
pub revocation_db_path: Option<PathBuf>,
#[serde(default)]
pub api_keys_db_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AclConfig {
pub preset_path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogConfig {
pub format: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbedConfig {
#[serde(default = "default_embed_enabled")]
pub enabled: bool,
#[serde(default = "default_embed_endpoint")]
pub endpoint: String,
#[serde(default = "default_embed_model")]
pub model: String,
#[serde(default = "default_embed_dim")]
pub dim: u16,
#[serde(default = "default_embed_timeout_ms")]
pub timeout_ms: u64,
}
fn default_embed_enabled() -> bool {
true
}
fn default_embed_endpoint() -> String {
"http://localhost:8436/v1/embeddings".to_string()
}
fn default_embed_model() -> String {
"bge-m3-Q8_0".to_string()
}
fn default_embed_dim() -> u16 {
1024
}
fn default_embed_timeout_ms() -> u64 {
5000
}
impl Default for EmbedConfig {
fn default() -> Self {
Self {
enabled: default_embed_enabled(),
endpoint: default_embed_endpoint(),
model: default_embed_model(),
dim: default_embed_dim(),
timeout_ms: default_embed_timeout_ms(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimitConfig {
#[serde(default = "default_ratelimit_enabled")]
pub enabled: bool,
#[serde(default = "default_ratelimit_per_minute")]
pub per_minute: u32,
#[serde(default = "default_ratelimit_burst")]
pub burst: u32,
#[serde(default = "default_ratelimit_exempt_localhost")]
pub exempt_localhost: bool,
}
fn default_ratelimit_enabled() -> bool {
true
}
fn default_ratelimit_per_minute() -> u32 {
60
}
fn default_ratelimit_burst() -> u32 {
10
}
fn default_ratelimit_exempt_localhost() -> bool {
true
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
enabled: default_ratelimit_enabled(),
per_minute: default_ratelimit_per_minute(),
burst: default_ratelimit_burst(),
exempt_localhost: default_ratelimit_exempt_localhost(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventLogConfig {
#[serde(default = "default_event_log_retention_days")]
pub retention_days: u64,
#[serde(default = "default_event_log_purge_interval_secs")]
pub purge_interval_secs: u64,
#[serde(default = "default_event_log_max_rows")]
pub max_rows: u64,
}
fn default_event_log_retention_days() -> u64 {
30
}
fn default_event_log_purge_interval_secs() -> u64 {
21_600 }
fn default_event_log_max_rows() -> u64 {
5_000_000
}
impl Default for EventLogConfig {
fn default() -> Self {
Self {
retention_days: default_event_log_retention_days(),
purge_interval_secs: default_event_log_purge_interval_secs(),
max_rows: default_event_log_max_rows(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CuratorConfig {
#[serde(default = "default_curator_backend")]
pub backend: String,
#[serde(default)]
pub llm: Option<LlmConfig>,
}
fn default_curator_backend() -> String {
"heuristic".to_string()
}
impl Default for CuratorConfig {
fn default() -> Self {
Self {
backend: default_curator_backend(),
llm: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmConfig {
pub backend: String,
pub base_url: String,
pub model: String,
#[serde(default)]
pub api_key_env: Option<String>,
#[serde(default = "default_timeout_ms")]
pub timeout_ms: u64,
#[serde(default)]
pub fallback: Option<Box<LlmConfig>>,
}
fn default_timeout_ms() -> u64 {
5000
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
server: HttpConfig {
bind: "127.0.0.1:19090"
.parse()
.expect("adresse loopback par défaut invalide — constante littérale"),
metrics_bind: "127.0.0.1:19091"
.parse()
.expect("adresse métriques loopback par défaut invalide — constante littérale"),
tls: None,
},
storage: StorageConfig {
root: PathBuf::from("/var/lib/gradatum"),
vault_index_path: PathBuf::from("/var/lib/gradatum/db/index.sqlite"),
legacy_alias_used: false,
},
auth: AuthConfig {
jwt_public_key_path: PathBuf::from("/var/lib/gradatum/config/jwt.public.pem"),
jwt_private_key_path: PathBuf::from("/var/lib/gradatum/config/jwt.private.pem"),
jwt_ttl_human_secs: 3600,
jwt_ttl_service_secs: 86400,
revocation_store: "sqlite".to_string(),
revocation_db_path: None,
api_keys_db_path: None,
},
acl: AclConfig {
preset_path: PathBuf::from("/var/lib/gradatum/config/bearer.toml"),
},
log: LogConfig {
format: "json".to_string(),
},
curator: CuratorConfig::default(),
ratelimit: RateLimitConfig::default(),
embed: EmbedConfig::default(),
event_log: EventLogConfig::default(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("erreur figment : {0}")]
Figment(Box<figment::Error>),
#[error(
"bind/TLS fail-closed : bind={bind} est non-loopback sans TLS configuré. \
Conseil : définir [server.tls] ou changer bind à 127.0.0.1:19090"
)]
BindTlsRefused { bind: SocketAddr },
}
impl From<figment::Error> for ConfigError {
fn from(e: figment::Error) -> Self {
ConfigError::Figment(Box::new(e))
}
}
impl ServerConfig {
pub fn load(toml_path: Option<&std::path::Path>) -> Result<Self, ConfigError> {
let mut fig = Figment::from(Serialized::defaults(Self::default()));
if let Some(p) = toml_path {
fig = fig.merge(Toml::file(p));
}
fig = fig.merge(Env::prefixed("GRADATUM_").split("__"));
let cfg: Self = fig.extract()?;
cfg.validate_bind_tls()?;
Ok(cfg)
}
pub fn validate_bind_tls(&self) -> Result<(), ConfigError> {
let is_loopback = self.server.bind.ip().is_loopback();
match (is_loopback, self.server.tls.is_some()) {
(true, _) => Ok(()),
(false, true) => Ok(()),
(false, false) => Err(ConfigError::BindTlsRefused {
bind: self.server.bind,
}),
}
}
}
#[cfg(test)]
mod ratelimit_tests {
use super::*;
#[test]
fn ratelimit_section_defaults_when_absent() {
let toml = r#"
[server]
bind = "127.0.0.1:19090"
metrics_bind = "127.0.0.1:19091"
[storage]
root = "/var/lib/gradatum"
vault_index_path = "/var/lib/gradatum/db/index.sqlite"
[auth]
jwt_public_key_path = "/x"
jwt_private_key_path = "/y"
jwt_ttl_human_secs = 3600
jwt_ttl_service_secs = 86400
revocation_store = "memory"
[acl]
preset_path = "/x"
[log]
format = "json"
"#;
let cfg: ServerConfig = toml::from_str(toml).expect("parse");
assert!(cfg.ratelimit.enabled);
assert_eq!(cfg.ratelimit.per_minute, 60);
assert_eq!(cfg.ratelimit.burst, 10);
assert!(cfg.ratelimit.exempt_localhost);
}
#[test]
fn ratelimit_section_custom() {
let toml = r#"
[server]
bind = "127.0.0.1:19090"
metrics_bind = "127.0.0.1:19091"
[storage]
root = "/var/lib/gradatum"
vault_index_path = "/var/lib/gradatum/db/index.sqlite"
[auth]
jwt_public_key_path = "/x"
jwt_private_key_path = "/y"
jwt_ttl_human_secs = 3600
jwt_ttl_service_secs = 86400
revocation_store = "memory"
[acl]
preset_path = "/x"
[log]
format = "json"
[ratelimit]
enabled = false
per_minute = 120
burst = 20
exempt_localhost = false
"#;
let cfg: ServerConfig = toml::from_str(toml).expect("parse");
assert!(!cfg.ratelimit.enabled);
assert_eq!(cfg.ratelimit.per_minute, 120);
assert_eq!(cfg.ratelimit.burst, 20);
assert!(!cfg.ratelimit.exempt_localhost);
}
}
#[cfg(test)]
mod storage_alias_tests {
use super::*;
#[test]
fn loads_with_vault_index_path() {
let toml = r#"
root = "/var/lib/gradatum"
vault_index_path = "/var/lib/gradatum/vault/.gradatum/index.db"
"#;
let cfg: StorageConfig = toml::from_str(toml).expect("parse");
assert_eq!(
cfg.vault_index_path,
std::path::PathBuf::from("/var/lib/gradatum/vault/.gradatum/index.db")
);
assert!(!cfg.legacy_alias_used());
}
#[test]
fn loads_with_legacy_db_path_alias() {
let toml = r#"
root = "/var/lib/gradatum"
db_path = "/var/lib/gradatum/db/index.sqlite"
"#;
let cfg: StorageConfig = toml::from_str(toml).expect("parse");
assert_eq!(
cfg.vault_index_path,
std::path::PathBuf::from("/var/lib/gradatum/db/index.sqlite")
);
assert!(cfg.legacy_alias_used(), "alias legacy doit être détecté");
}
#[test]
fn default_vault_index_path_when_absent() {
let toml = r#"
root = "/var/lib/gradatum"
"#;
let cfg: StorageConfig = toml::from_str(toml).expect("parse");
assert_eq!(
cfg.vault_index_path,
std::path::PathBuf::from("/var/lib/gradatum/db/index.sqlite")
);
assert!(!cfg.legacy_alias_used());
}
#[test]
fn both_keys_uses_canonical() {
let toml = r#"
root = "/var/lib/gradatum"
vault_index_path = "/canonical.db"
db_path = "/legacy.db"
"#;
let cfg: StorageConfig = toml::from_str(toml).expect("parse");
assert_eq!(
cfg.vault_index_path,
std::path::PathBuf::from("/canonical.db")
);
assert!(cfg.legacy_alias_used(), "doublon doit lever flag");
}
}
#[cfg(test)]
mod embed_tests {
use super::*;
#[test]
fn embed_section_defaults() {
let toml = r#"
[server]
bind = "127.0.0.1:19090"
metrics_bind = "127.0.0.1:19091"
[storage]
root = "/var/lib/gradatum"
vault_index_path = "/var/lib/gradatum/db/index.sqlite"
[auth]
jwt_public_key_path = "/x"
jwt_private_key_path = "/y"
jwt_ttl_human_secs = 3600
jwt_ttl_service_secs = 86400
revocation_store = "memory"
[acl]
preset_path = "/x"
[log]
format = "json"
"#;
let cfg: ServerConfig = toml::from_str(toml).expect("parse");
assert!(cfg.embed.enabled);
assert_eq!(cfg.embed.endpoint, "http://localhost:8436/v1/embeddings");
assert_eq!(cfg.embed.model, "bge-m3-Q8_0");
assert_eq!(cfg.embed.dim, 1024);
assert_eq!(cfg.embed.timeout_ms, 5000);
}
#[test]
fn embed_defaults_match_documented_values() {
let cfg = EmbedConfig::default();
assert_eq!(
cfg.model, "bge-m3-Q8_0",
"default model must be bge-m3-Q8_0"
);
assert_eq!(cfg.dim, 1024, "default dim must be 1024");
assert!(cfg.enabled, "embed activé par défaut");
assert_eq!(cfg.endpoint, "http://localhost:8436/v1/embeddings");
assert_eq!(cfg.timeout_ms, 5000);
}
}