use serde::Deserialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct ServerConfig {
pub server: ServerSection,
pub tls: TlsSection,
pub encryption: EncryptionSection,
pub embedding: EmbeddingSection,
pub background: BackgroundSection,
pub limits: LimitsSection,
pub cluster: ClusterSection,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ServerSection {
pub wire_port: u16,
pub http_port: u16,
pub data_dir: PathBuf,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct EmbeddingSection {
pub strategy: EmbeddingStrategy,
pub dim: usize,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct TlsSection {
pub cert_path: Option<PathBuf>,
pub key_path: Option<PathBuf>,
}
impl TlsSection {
pub fn is_enabled(&self) -> bool {
self.cert_path.is_some() && self.key_path.is_some()
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct EncryptionSection {
pub key_path: Option<PathBuf>,
pub auto_generate: bool,
pub key_hex: Option<String>,
}
impl EncryptionSection {
#[allow(dead_code)]
pub fn is_enabled(&self) -> bool {
self.key_path.is_some() || self.key_hex.is_some() || self.auto_generate
}
pub fn resolve_key(&self, data_dir: &Path) -> anyhow::Result<Option<[u8; 32]>> {
if let Some(ref hex_str) = self.key_hex {
let bytes = hex::decode(hex_str)
.map_err(|e| anyhow::anyhow!("invalid encryption.key_hex: {}", e))?;
if bytes.len() != 32 {
anyhow::bail!("encryption.key_hex must decode to exactly 32 bytes");
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
return Ok(Some(key));
}
let path = match &self.key_path {
Some(p) => p.clone(),
None if self.auto_generate => data_dir.join("master.key"),
None => return Ok(None),
};
if path.exists() {
let bytes = std::fs::read(&path)?;
if bytes.len() != 32 {
anyhow::bail!(
"key file at {} must be exactly 32 bytes (got {})",
path.display(),
bytes.len()
);
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
return Ok(Some(key));
}
if self.auto_generate {
use rand::RngCore;
let mut key = [0u8; 32];
rand::thread_rng().fill_bytes(&mut key);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, key)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
tracing::info!(
path = %path.display(),
"auto-generated encryption master key"
);
return Ok(Some(key));
}
Ok(None)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EmbeddingStrategy {
Builtin,
ClientOnly,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct BackgroundSection {
pub consolidation_interval_minutes: u64,
pub decay_sweep_interval_minutes: u64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct LimitsSection {
pub max_databases: usize,
pub max_connections: usize,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ClusterSection {
pub node_id: u32,
pub role: NodeRole,
pub cluster_port: u16,
pub advertise_addr: Option<String>,
pub peers: Vec<PeerConfig>,
pub heartbeat_interval_ms: u64,
pub election_timeout_ms: u64,
pub cluster_secret: Option<String>,
pub replication_mode: ReplicationMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NodeRole {
Single,
Voter,
ReadReplica,
Witness,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReplicationMode {
Async,
Sync,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PeerConfig {
pub addr: String,
pub role: NodeRole,
}
impl Default for ClusterSection {
fn default() -> Self {
Self {
node_id: 0,
role: NodeRole::Single,
cluster_port: 7440,
advertise_addr: None,
peers: Vec::new(),
heartbeat_interval_ms: 1000,
election_timeout_ms: 5000,
cluster_secret: None,
replication_mode: ReplicationMode::Async,
}
}
}
impl ClusterSection {
pub fn is_clustered(&self) -> bool {
self.role != NodeRole::Single
}
#[allow(dead_code)]
pub fn voter_count(&self) -> usize {
let self_voter = matches!(self.role, NodeRole::Voter) as usize;
let peer_voters = self
.peers
.iter()
.filter(|p| p.role == NodeRole::Voter)
.count();
self_voter + peer_voters
}
pub fn quorum_members(&self) -> usize {
let self_member = matches!(self.role, NodeRole::Voter | NodeRole::Witness) as usize;
let peer_members = self
.peers
.iter()
.filter(|p| matches!(p.role, NodeRole::Voter | NodeRole::Witness))
.count();
self_member + peer_members
}
pub fn quorum_size(&self) -> usize {
let total = self.quorum_members();
total / 2 + 1
}
}
impl Default for ServerSection {
fn default() -> Self {
Self {
wire_port: 7437,
http_port: 7438,
data_dir: PathBuf::from("./data"),
}
}
}
impl Default for EmbeddingSection {
fn default() -> Self {
Self {
strategy: EmbeddingStrategy::Builtin,
dim: 384,
}
}
}
impl Default for BackgroundSection {
fn default() -> Self {
Self {
consolidation_interval_minutes: 30,
decay_sweep_interval_minutes: 60,
}
}
}
impl Default for LimitsSection {
fn default() -> Self {
Self {
max_databases: 100,
max_connections: 1000,
}
}
}
impl ServerConfig {
pub fn load(path: &Path) -> anyhow::Result<Self> {
let content = std::fs::read_to_string(path)?;
let config: ServerConfig = toml::from_str(&content)?;
Ok(config)
}
pub fn data_dir(&self) -> &Path {
&self.server.data_dir
}
pub fn control_db_path(&self) -> PathBuf {
self.server.data_dir.join("control.db")
}
}