use super::*;
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct DbConfig {
pub deploy: BackendDeployConfig,
pub host: String,
pub port: u16,
pub database: String,
pub role: String,
pub password: String,
pub ssl_mode: String,
pub pooler_dsn: String,
pub direct_dsn: String,
pub max_open_conns: i32,
pub max_idle_conns: i32,
pub conn_max_lifetime_secs: u64,
pub conn_max_idle_secs: u64,
#[serde(default = "five_i32")]
pub min_connections: i32,
#[serde(default = "ten_u64")]
pub acquire_timeout_secs: u64,
}
impl std::fmt::Debug for DbConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DbConfig")
.field("deploy", &self.deploy)
.field("host", &self.host)
.field("port", &self.port)
.field("database", &self.database)
.field("role", &self.role)
.field("password", &"[redacted]")
.field("ssl_mode", &self.ssl_mode)
.field("pooler_dsn", &"[redacted]")
.field("direct_dsn", &"[redacted]")
.field("max_open_conns", &self.max_open_conns)
.field("max_idle_conns", &self.max_idle_conns)
.field("conn_max_lifetime_secs", &self.conn_max_lifetime_secs)
.field("conn_max_idle_secs", &self.conn_max_idle_secs)
.field("min_connections", &self.min_connections)
.field("acquire_timeout_secs", &self.acquire_timeout_secs)
.finish()
}
}
impl DbConfig {
pub fn is_configured(&self) -> bool {
!self.host.is_empty() && !self.database.is_empty() && !self.role.is_empty()
}
pub fn effective_ssl_mode(&self) -> &str {
if !self.ssl_mode.is_empty() {
return &self.ssl_mode;
}
match self.deploy.mode {
DeployMode::Cloud => "require",
DeployMode::SelfHosted => "prefer",
}
}
pub fn masked_log(&self) -> String {
format!(
"host={} db={} role={} ssl={} deploy={}",
self.host,
self.database,
self.role,
self.effective_ssl_mode(),
self.deploy.mode.as_str(),
)
}
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct RedisConfig {
pub deploy: BackendDeployConfig,
pub host: String,
pub port: u16,
pub password: String,
pub database: u8,
pub pool_size: u32,
pub min_idle_conns: u32,
pub max_retries: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dsn: Option<String>,
pub tls_enabled: bool,
pub default_ttl_secs: u64,
pub mode: String,
pub sentinel_master: String,
}
impl std::fmt::Debug for RedisConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RedisConfig")
.field("deploy", &self.deploy)
.field("host", &self.host)
.field("port", &self.port)
.field("password", &"[redacted]")
.field("database", &self.database)
.field("pool_size", &self.pool_size)
.field("min_idle_conns", &self.min_idle_conns)
.field("max_retries", &self.max_retries)
.field("dsn", &self.dsn.as_ref().map(|_| "[redacted]"))
.field("tls_enabled", &self.tls_enabled)
.field("default_ttl_secs", &self.default_ttl_secs)
.field("mode", &self.mode)
.field("sentinel_master", &self.sentinel_master)
.finish()
}
}
impl RedisConfig {
pub fn is_configured(&self) -> bool {
!self.host.is_empty() || self.dsn.as_ref().map(|d| !d.is_empty()).unwrap_or(false)
}
pub fn effective_tls(&self) -> bool {
if self.tls_enabled {
return true;
}
self.deploy.tls_required()
}
pub fn masked_log(&self) -> String {
format!(
"host={}:{} db={} tls={} mode={} deploy={}",
self.host,
self.port,
self.database,
self.effective_tls(),
self.mode,
self.deploy.mode.as_str(),
)
}
pub fn effective_port(&self) -> u16 {
if self.port > 0 { self.port } else { 6379 }
}
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct QdrantConfig {
pub deploy: BackendDeployConfig,
pub host: String,
pub grpc_port: u16,
pub http_port: u16,
pub api_key: String,
pub tls_enabled: bool,
pub default_dimension: u32,
pub default_distance: String,
pub max_retries: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
impl std::fmt::Debug for QdrantConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("QdrantConfig")
.field("deploy", &self.deploy)
.field("host", &self.host)
.field("grpc_port", &self.grpc_port)
.field("http_port", &self.http_port)
.field("api_key", &"[redacted]")
.field("tls_enabled", &self.tls_enabled)
.field("default_dimension", &self.default_dimension)
.field("default_distance", &self.default_distance)
.field("max_retries", &self.max_retries)
.field("url", &self.url)
.finish()
}
}
impl QdrantConfig {
pub fn is_configured(&self) -> bool {
!self.host.is_empty() || self.url.as_ref().map(|u| !u.is_empty()).unwrap_or(false)
}
pub fn effective_tls(&self) -> bool {
if self.tls_enabled {
return true;
}
self.deploy.tls_required()
}
pub fn masked_log(&self) -> String {
format!(
"host={} grpc={} http={} tls={} dim={} dist={} deploy={}",
self.host,
self.effective_grpc_port(),
self.effective_http_port(),
self.effective_tls(),
self.default_dimension,
self.default_distance,
self.deploy.mode.as_str(),
)
}
pub fn effective_grpc_port(&self) -> u16 {
if self.grpc_port > 0 {
self.grpc_port
} else {
6334
}
}
pub fn effective_http_port(&self) -> u16 {
if self.http_port > 0 {
self.http_port
} else {
6333
}
}
pub fn effective_distance(&self) -> &str {
if self.default_distance.is_empty() {
"Cosine"
} else {
&self.default_distance
}
}
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct MinioConfig {
pub deploy: BackendDeployConfig,
pub endpoint: String,
pub access_key: String,
pub secret_key: String,
pub use_ssl: bool,
pub bucket_prefix: String,
pub region: String,
pub max_retries: u32,
pub multipart_part_size_bytes: u64,
}
impl std::fmt::Debug for MinioConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MinioConfig")
.field("deploy", &self.deploy)
.field("endpoint", &self.endpoint)
.field("access_key", &"[redacted]")
.field("secret_key", &"[redacted]")
.field("use_ssl", &self.use_ssl)
.field("bucket_prefix", &self.bucket_prefix)
.field("region", &self.region)
.field("max_retries", &self.max_retries)
.field("multipart_part_size_bytes", &self.multipart_part_size_bytes)
.finish()
}
}
impl MinioConfig {
pub fn is_configured(&self) -> bool {
!self.endpoint.is_empty()
}
pub fn effective_ssl(&self) -> bool {
if self.use_ssl {
return true;
}
self.deploy.tls_required()
}
pub fn masked_log(&self) -> String {
format!(
"endpoint={} region={} ssl={} prefix={} deploy={}",
self.endpoint,
self.effective_region(),
self.effective_ssl(),
self.bucket_prefix,
self.deploy.mode.as_str(),
)
}
pub fn effective_region(&self) -> &str {
if self.region.is_empty() {
"us-east-1"
} else {
&self.region
}
}
pub fn qualified_bucket(&self, logical_name: &str) -> String {
format!("{}{}", self.bucket_prefix, logical_name)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct FailoverConfig {
pub enabled: bool,
pub failure_threshold: u32,
pub cooldown: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AuditSinkKind {
#[default]
None,
Stdout,
File,
Kafka,
Postgres,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct AuditSinkConfig {
pub kind: AuditSinkKind,
pub file_path: Option<String>,
pub kafka_topic: Option<String>,
pub kafka_brokers: Option<String>,
pub pg_table: Option<String>,
pub min_severity: String,
}
impl Default for AuditSinkConfig {
fn default() -> Self {
Self {
kind: AuditSinkKind::default(),
file_path: None,
kafka_topic: None,
kafka_brokers: None,
pg_table: None,
min_severity: "info".to_string(),
}
}
}
impl AuditSinkConfig {
pub fn from_env() -> Self {
let kind = match std::env::var("UDB_AUDIT_SINK")
.unwrap_or_default()
.to_lowercase()
.as_str()
{
"stdout" => AuditSinkKind::Stdout,
"file" => AuditSinkKind::File,
"kafka" => AuditSinkKind::Kafka,
"postgres" | "pg" => AuditSinkKind::Postgres,
_ => AuditSinkKind::None,
};
let min_severity = std::env::var("UDB_AUDIT_MIN_SEVERITY")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "info".to_string());
Self {
kind,
file_path: std::env::var("UDB_AUDIT_FILE_PATH")
.ok()
.filter(|v| !v.is_empty()),
kafka_topic: std::env::var("UDB_AUDIT_KAFKA_TOPIC")
.ok()
.filter(|v| !v.is_empty()),
kafka_brokers: std::env::var("UDB_AUDIT_KAFKA_BROKERS")
.ok()
.filter(|v| !v.is_empty()),
pg_table: std::env::var("UDB_AUDIT_PG_TABLE")
.ok()
.filter(|v| !v.is_empty()),
min_severity,
}
}
pub fn merge_env(&mut self) {
if let Ok(raw) = std::env::var("UDB_AUDIT_SINK") {
self.kind = match raw.to_lowercase().as_str() {
"stdout" => AuditSinkKind::Stdout,
"file" => AuditSinkKind::File,
"kafka" => AuditSinkKind::Kafka,
"postgres" | "pg" => AuditSinkKind::Postgres,
_ => AuditSinkKind::None,
};
}
if let Some(v) = std::env::var("UDB_AUDIT_MIN_SEVERITY")
.ok()
.filter(|v| !v.is_empty())
{
self.min_severity = v;
}
if let Some(v) = std::env::var("UDB_AUDIT_FILE_PATH")
.ok()
.filter(|v| !v.is_empty())
{
self.file_path = Some(v);
}
if let Some(v) = std::env::var("UDB_AUDIT_KAFKA_TOPIC")
.ok()
.filter(|v| !v.is_empty())
{
self.kafka_topic = Some(v);
}
if let Some(v) = std::env::var("UDB_AUDIT_KAFKA_BROKERS")
.ok()
.filter(|v| !v.is_empty())
{
self.kafka_brokers = Some(v);
}
if let Some(v) = std::env::var("UDB_AUDIT_PG_TABLE")
.ok()
.filter(|v| !v.is_empty())
{
self.pg_table = Some(v);
}
}
pub fn is_active(&self) -> bool {
self.kind != AuditSinkKind::None
}
pub fn validate(&self) -> Vec<String> {
let mut errors = Vec::new();
match self.kind {
AuditSinkKind::File if self.file_path.is_none() => {
errors
.push("UDB_AUDIT_SINK=file requires UDB_AUDIT_FILE_PATH to be set".to_string());
}
AuditSinkKind::Kafka if self.kafka_topic.is_none() => {
errors.push(
"UDB_AUDIT_SINK=kafka requires UDB_AUDIT_KAFKA_TOPIC to be set".to_string(),
);
}
AuditSinkKind::Kafka if self.kafka_brokers.is_none() => {
errors.push(
"UDB_AUDIT_SINK=kafka requires UDB_AUDIT_KAFKA_BROKERS to be set".to_string(),
);
}
AuditSinkKind::Postgres if self.pg_table.is_none() => {
errors.push(
"UDB_AUDIT_SINK=postgres requires UDB_AUDIT_PG_TABLE to be set".to_string(),
);
}
_ => {}
}
match self.min_severity.as_str() {
"" | "info" | "warn" | "error" => {}
other => errors.push(format!(
"UDB_AUDIT_MIN_SEVERITY='{other}' is not valid; use 'info', 'warn', or 'error'"
)),
}
errors
}
}
#[cfg(test)]
mod secret_no_leak {
use super::*;
const CANARY: &str = "udb-canary-SECRET";
#[test]
fn db_config_debug_redacts_secrets() {
let cfg = DbConfig {
password: CANARY.to_string(),
pooler_dsn: format!("postgres://u:{CANARY}@h/db"),
direct_dsn: format!("postgres://u:{CANARY}@h/db"),
host: "h".to_string(),
..Default::default()
};
let dbg = format!("{cfg:?}");
assert!(!dbg.contains(CANARY), "DbConfig leaked a secret: {dbg}");
assert!(dbg.contains("host"), "DbConfig dropped debuggable fields");
}
#[test]
fn redis_config_debug_redacts_secrets() {
let cfg = RedisConfig {
password: CANARY.to_string(),
dsn: Some(format!("redis://:{CANARY}@h")),
..Default::default()
};
let dbg = format!("{cfg:?}");
assert!(!dbg.contains(CANARY), "RedisConfig leaked a secret: {dbg}");
}
#[test]
fn qdrant_config_debug_redacts_secrets() {
let cfg = QdrantConfig {
api_key: CANARY.to_string(),
..Default::default()
};
let dbg = format!("{cfg:?}");
assert!(!dbg.contains(CANARY), "QdrantConfig leaked a secret: {dbg}");
}
#[test]
fn minio_config_debug_redacts_secrets() {
let cfg = MinioConfig {
access_key: CANARY.to_string(),
secret_key: CANARY.to_string(),
..Default::default()
};
let dbg = format!("{cfg:?}");
assert!(!dbg.contains(CANARY), "MinioConfig leaked a secret: {dbg}");
}
}