velesdb_server/config.rs
1//! Server configuration module.
2//!
3//! Loads configuration from multiple sources with priority:
4//! CLI flags > environment variables > velesdb.toml > defaults.
5
6use serde::Deserialize;
7use std::path::{Path, PathBuf};
8
9// ============================================================================
10// Core engine configuration (issue #1549)
11// ============================================================================
12
13/// Loads the core [`velesdb_core::config::VelesConfig`] (search/HNSW/storage/
14/// limits/quantization/WAL batching) from the same TOML file consumed by
15/// [`ServerConfig::load`] for the server-transport sections (`[server]`,
16/// `[auth]`, `[tls]`, `[cors]`).
17///
18/// The two structs deserialize independently from the same file, but
19/// **`VelesConfig` also has its own `[server]` and `[logging]` fields**
20/// (for standalone/embedded consumers) that collide in key name — not
21/// meaning — with this crate's own `[server]` transport section. A
22/// `[server] port = 443` meant as this crate's HTTP bind port would
23/// otherwise *also* land in `VelesConfig.server.port` and be rejected by
24/// its `>= 1024` validation rule, a spurious failure unrelated to the
25/// value actually being configured. This function therefore uses
26/// [`velesdb_core::config::VelesConfig::load_from_path_engine_only`],
27/// which parses **only** the engine sections (`[search]`/`[hnsw]`/
28/// `[storage]`/`[limits]`/`[quantization]`/`[wal_batch]`) and silently
29/// drops every other top-level table before validating — so
30/// `[server]`/`[auth]`/`[tls]`/`[cors]` stay exclusively owned by
31/// [`ServerConfig::load`].
32///
33/// `VELESDB_*` environment variables still layer on top of the (filtered)
34/// file, same as [`velesdb_core::config::VelesConfig::load_from_path`] —
35/// e.g. `VELESDB_LIMITS_MAX_COLLECTIONS=5` overrides a `[limits]` value
36/// from the file even though the file is filtered first.
37///
38/// - `config_path: Some(path)` — the caller passed `--config`/`VELESDB_CONFIG`
39/// explicitly. The file **must** exist and pass
40/// `load_from_path_engine_only` validation — never a silent fallback to
41/// defaults on a broken path.
42/// - `config_path: None` — mirrors [`load_toml_file`]'s default-file
43/// behaviour: falls back to `velesdb.toml` in the current directory if
44/// present (still validated, so a malformed default file still fails
45/// fast), otherwise core defaults apply.
46///
47/// # Errors
48///
49/// Returns an error if an explicit path is missing, or if the file fails to
50/// parse or validate. The underlying typed
51/// [`velesdb_core::config::ConfigError`] is preserved as the error source so
52/// callers can match on it if needed.
53pub fn load_core_config(
54 config_path: &Option<PathBuf>,
55) -> anyhow::Result<velesdb_core::config::VelesConfig> {
56 let path = match config_path {
57 Some(p) => {
58 if !p.exists() {
59 anyhow::bail!("config file not found: {}", p.display());
60 }
61 p.clone()
62 }
63 None => {
64 let default_path = PathBuf::from("velesdb.toml");
65 if !default_path.exists() {
66 return Ok(velesdb_core::config::VelesConfig::default());
67 }
68 default_path
69 }
70 };
71
72 velesdb_core::config::VelesConfig::load_from_path_engine_only(&path).map_err(|e| {
73 anyhow::anyhow!(
74 "failed to load VelesDB core config from {}: {e}",
75 path.display()
76 )
77 })
78}
79
80// ============================================================================
81// TOML file configuration (all fields optional)
82// ============================================================================
83
84/// Root structure for `velesdb.toml`.
85#[derive(Debug, Deserialize, Default)]
86struct FileConfig {
87 server: Option<ServerSection>,
88 auth: Option<AuthSection>,
89 tls: Option<TlsSection>,
90 cors: Option<CorsSection>,
91}
92
93#[derive(Debug, Deserialize, Default)]
94struct ServerSection {
95 host: Option<String>,
96 port: Option<u16>,
97 data_dir: Option<String>,
98 shutdown_timeout_secs: Option<u64>,
99 rate_limit: Option<u32>,
100}
101
102#[derive(Debug, Deserialize, Default)]
103struct AuthSection {
104 api_keys: Option<Vec<String>>,
105}
106
107#[derive(Debug, Deserialize, Default)]
108struct TlsSection {
109 cert: Option<String>,
110 key: Option<String>,
111}
112
113#[derive(Debug, Deserialize, Default)]
114struct CorsSection {
115 allowed_origins: Option<Vec<String>>,
116 allowed_methods: Option<Vec<String>>,
117 allowed_headers: Option<Vec<String>>,
118 allow_credentials: Option<bool>,
119 max_age_secs: Option<u64>,
120}
121
122// ============================================================================
123// Resolved configuration
124// ============================================================================
125
126/// TLS certificate and key paths.
127///
128/// Both fields must be `Some` together or both `None`; a partial pair is
129/// rejected by [`ServerConfig::validate`].
130#[derive(Debug, Clone, PartialEq, Default)]
131pub struct TlsConfig {
132 /// Path to the PEM-encoded TLS certificate file.
133 pub cert: Option<String>,
134 /// Path to the PEM-encoded TLS private key file.
135 pub key: Option<String>,
136}
137
138impl TlsConfig {
139 /// Returns `true` when both cert and key are configured.
140 pub fn is_enabled(&self) -> bool {
141 self.cert.is_some() && self.key.is_some()
142 }
143}
144
145/// Final resolved server configuration.
146#[derive(Debug, Clone, PartialEq)]
147pub struct ServerConfig {
148 pub host: String,
149 pub port: u16,
150 pub data_dir: String,
151 pub api_keys: Vec<String>,
152 /// TLS certificate and key configuration (both or neither).
153 pub tls: TlsConfig,
154 pub shutdown_timeout_secs: u64,
155 /// Maximum requests per second per IP address (0 = disabled).
156 pub rate_limit: u32,
157 /// CORS configuration for cross-origin requests.
158 pub cors: CorsConfig,
159}
160
161/// CORS configuration for the server.
162///
163/// When `allowed_origins` contains `"*"`, the server uses a fully permissive
164/// CORS policy (equivalent to `CorsLayer::permissive()`). Otherwise, only the
165/// listed origins are allowed.
166///
167/// Defaults to permissive (`["*"]`) for backward compatibility.
168#[derive(Debug, Clone, PartialEq)]
169pub struct CorsConfig {
170 /// Allowed origins. Use `["*"]` for permissive mode.
171 pub allowed_origins: Vec<String>,
172 /// Allowed HTTP methods (e.g. `["GET", "POST"]`).
173 pub allowed_methods: Vec<String>,
174 /// Allowed request headers (e.g. `["Content-Type", "Authorization"]`).
175 /// Use `["*"]` to allow any header.
176 pub allowed_headers: Vec<String>,
177 /// Whether to allow credentials (cookies, authorization headers).
178 pub allow_credentials: bool,
179 /// How long (in seconds) browsers may cache preflight responses.
180 pub max_age_secs: u64,
181}
182
183/// Default burst budget for rate limiting (requests per second per IP).
184const DEFAULT_RATE_LIMIT: u32 = 100;
185
186/// Default preflight cache duration in seconds (1 hour).
187const DEFAULT_CORS_MAX_AGE_SECS: u64 = 3600;
188
189impl Default for CorsConfig {
190 fn default() -> Self {
191 Self {
192 allowed_origins: vec!["*".to_string()],
193 allowed_methods: vec![
194 "GET".to_string(),
195 "POST".to_string(),
196 "PUT".to_string(),
197 "DELETE".to_string(),
198 "PATCH".to_string(),
199 "OPTIONS".to_string(),
200 ],
201 allowed_headers: vec!["*".to_string()],
202 allow_credentials: false,
203 max_age_secs: DEFAULT_CORS_MAX_AGE_SECS,
204 }
205 }
206}
207
208impl CorsConfig {
209 /// Returns `true` when CORS is in fully permissive mode (any origin).
210 pub fn is_permissive(&self) -> bool {
211 self.allowed_origins.iter().any(|o| o == "*")
212 }
213}
214
215impl Default for ServerConfig {
216 fn default() -> Self {
217 Self {
218 host: "127.0.0.1".to_string(),
219 port: 8080,
220 data_dir: "./velesdb_data".to_string(),
221 api_keys: Vec::new(),
222 tls: TlsConfig::default(),
223 shutdown_timeout_secs: 30,
224 rate_limit: DEFAULT_RATE_LIMIT,
225 cors: CorsConfig::default(),
226 }
227 }
228}
229
230// ============================================================================
231// Loading logic
232// ============================================================================
233
234impl ServerConfig {
235 /// Load configuration with priority: CLI > env > TOML file > defaults.
236 ///
237 /// `cli` contains values from clap (which merges CLI flags + env vars).
238 /// `cli_sources` indicates which fields were explicitly set via CLI/env
239 /// (as opposed to falling back to clap defaults).
240 pub fn load(cli: CliOverrides) -> anyhow::Result<Self> {
241 let defaults = Self::default();
242 let file_cfg = load_toml_file(&cli.config_path)?;
243 Ok(Self::merge(defaults, file_cfg, cli))
244 }
245
246 fn merge(defaults: Self, file: FileConfig, cli: CliOverrides) -> Self {
247 let server = file.server.unwrap_or_default();
248 let auth = file.auth.unwrap_or_default();
249 let tls = file.tls.unwrap_or_default();
250 let cors_section = file.cors.unwrap_or_default();
251
252 // Layer: TOML over defaults
253 let host = server.host.unwrap_or(defaults.host);
254 let port = server.port.unwrap_or(defaults.port);
255 let data_dir = server.data_dir.unwrap_or(defaults.data_dir);
256 let shutdown_timeout_secs = server
257 .shutdown_timeout_secs
258 .unwrap_or(defaults.shutdown_timeout_secs);
259 let rate_limit = server.rate_limit.unwrap_or(defaults.rate_limit);
260 let api_keys = auth.api_keys.unwrap_or(defaults.api_keys);
261 let tls = TlsConfig {
262 cert: tls.cert.or(defaults.tls.cert),
263 key: tls.key.or(defaults.tls.key),
264 };
265 let cors = resolve_cors(defaults.cors, cors_section);
266
267 // Layer: CLI/env over TOML (only override when explicitly set)
268 let host = cli.host.unwrap_or(host);
269 let port = cli.port.unwrap_or(port);
270 let data_dir = cli.data_dir.unwrap_or(data_dir);
271 let api_keys = cli.api_keys.unwrap_or(api_keys);
272 let tls = TlsConfig {
273 cert: cli.tls_cert.or(tls.cert),
274 key: cli.tls_key.or(tls.key),
275 };
276 let rate_limit = cli.rate_limit.unwrap_or(rate_limit);
277
278 Self {
279 host,
280 port,
281 data_dir,
282 api_keys,
283 tls,
284 shutdown_timeout_secs,
285 rate_limit,
286 cors,
287 }
288 }
289
290 /// Validate the configuration at startup.
291 pub fn validate(&self) -> anyhow::Result<()> {
292 if self.port == 0 {
293 anyhow::bail!("invalid port: 0 is not allowed");
294 }
295 if self.data_dir.is_empty() {
296 anyhow::bail!("data_dir must not be empty");
297 }
298
299 // TLS: both cert and key must be provided together
300 match (&self.tls.cert, &self.tls.key) {
301 (Some(_), None) => {
302 anyhow::bail!("tls_cert is set but tls_key is missing");
303 }
304 (None, Some(_)) => {
305 anyhow::bail!("tls_key is set but tls_cert is missing");
306 }
307 (Some(cert), Some(key)) => {
308 if !Path::new(cert).exists() {
309 anyhow::bail!("TLS cert file not found: {cert}");
310 }
311 if !Path::new(key).exists() {
312 anyhow::bail!("TLS key file not found: {key}");
313 }
314 }
315 (None, None) => {}
316 }
317
318 Ok(())
319 }
320
321 /// Returns `true` when API key authentication is enabled.
322 pub fn auth_enabled(&self) -> bool {
323 !self.api_keys.is_empty()
324 }
325
326 /// Returns `true` when TLS is configured.
327 pub fn tls_enabled(&self) -> bool {
328 self.tls.is_enabled()
329 }
330
331 /// Returns `true` when rate limiting is enabled (rate_limit > 0).
332 pub fn rate_limit_enabled(&self) -> bool {
333 self.rate_limit > 0
334 }
335
336 /// Returns `true` when the bind host is reachable beyond the local machine.
337 ///
338 /// A loopback host is treated as private: `localhost`, any `127.0.0.0/8`
339 /// address (`127.0.0.1`, `127.0.0.5`, …), IPv6 loopback `::1`, and the
340 /// IPv4-mapped form `::ffff:127.0.0.1`. Matching is case-insensitive and
341 /// tolerates surrounding whitespace and `[...]` brackets. Anything else —
342 /// including the wildcards `0.0.0.0`/`::` and any routable address or
343 /// hostname — is considered publicly reachable. Errs toward *over*-warning
344 /// (an unrecognised host is treated as public), never under-warning.
345 pub fn binds_publicly(&self) -> bool {
346 // Case-insensitive, whitespace- and bracket-tolerant (`[::1]`).
347 let host = self
348 .host
349 .trim()
350 .trim_start_matches('[')
351 .trim_end_matches(']')
352 .to_ascii_lowercase();
353 if matches!(host.as_str(), "localhost" | "::1") || host.starts_with("127.") {
354 return false;
355 }
356 // IPv4-mapped IPv6 loopback, e.g. `::ffff:127.0.0.1`.
357 if let Some(v4) = host.strip_prefix("::ffff:") {
358 if v4.starts_with("127.") {
359 return false;
360 }
361 }
362 true
363 }
364}
365
366// ============================================================================
367// CLI overrides (filled by clap in main.rs)
368// ============================================================================
369
370/// Values explicitly provided via CLI flags or environment variables.
371/// `None` means "not provided — fall through to TOML or default".
372#[derive(Debug, Default)]
373pub struct CliOverrides {
374 pub config_path: Option<PathBuf>,
375 pub host: Option<String>,
376 pub port: Option<u16>,
377 pub data_dir: Option<String>,
378 pub api_keys: Option<Vec<String>>,
379 pub tls_cert: Option<String>,
380 pub tls_key: Option<String>,
381 pub rate_limit: Option<u32>,
382}
383
384// ============================================================================
385// TOML file loader
386// ============================================================================
387
388fn load_toml_file(path: &Option<PathBuf>) -> anyhow::Result<FileConfig> {
389 let candidate = match path {
390 Some(p) => {
391 if !p.exists() {
392 anyhow::bail!("config file not found: {}", p.display());
393 }
394 p.clone()
395 }
396 None => {
397 let default_path = PathBuf::from("velesdb.toml");
398 if !default_path.exists() {
399 return Ok(FileConfig::default());
400 }
401 default_path
402 }
403 };
404
405 let contents = std::fs::read_to_string(&candidate)
406 .map_err(|e| anyhow::anyhow!("failed to read config file {}: {e}", candidate.display()))?;
407
408 let cfg: FileConfig = toml::from_str(&contents)
409 .map_err(|e| anyhow::anyhow!("failed to parse config file {}: {e}", candidate.display()))?;
410
411 Ok(cfg)
412}
413
414// ============================================================================
415// CORS resolution & layer builder
416// ============================================================================
417
418/// Merges a `CorsSection` (from TOML) over `CorsConfig` defaults.
419fn resolve_cors(defaults: CorsConfig, section: CorsSection) -> CorsConfig {
420 CorsConfig {
421 allowed_origins: section.allowed_origins.unwrap_or(defaults.allowed_origins),
422 allowed_methods: section.allowed_methods.unwrap_or(defaults.allowed_methods),
423 allowed_headers: section.allowed_headers.unwrap_or(defaults.allowed_headers),
424 allow_credentials: section
425 .allow_credentials
426 .unwrap_or(defaults.allow_credentials),
427 max_age_secs: section.max_age_secs.unwrap_or(defaults.max_age_secs),
428 }
429}
430
431/// Builds a [`tower_http::cors::CorsLayer`] from the resolved CORS config.
432///
433/// When `allowed_origins` contains `"*"`, returns `CorsLayer::permissive()`
434/// for full backward compatibility. Otherwise, constructs a restrictive
435/// layer with the specified origins, methods, and headers.
436pub fn build_cors_layer(cors: &CorsConfig) -> tower_http::cors::CorsLayer {
437 use tower_http::cors::{AllowOrigin, CorsLayer};
438
439 if cors.is_permissive() {
440 return CorsLayer::permissive();
441 }
442
443 let origins: Vec<axum::http::HeaderValue> = cors
444 .allowed_origins
445 .iter()
446 .filter_map(|o| o.parse().ok())
447 .collect();
448 let methods: Vec<axum::http::Method> = cors
449 .allowed_methods
450 .iter()
451 .filter_map(|m| m.parse().ok())
452 .collect();
453
454 let layer = CorsLayer::new()
455 .allow_origin(AllowOrigin::list(origins))
456 .allow_methods(methods)
457 .max_age(std::time::Duration::from_secs(cors.max_age_secs));
458
459 let layer = apply_cors_headers_policy(layer, cors);
460
461 if cors.allow_credentials {
462 layer.allow_credentials(true)
463 } else {
464 layer
465 }
466}
467
468/// Applies the headers policy to a `CorsLayer`, honouring the CORS spec rule that
469/// `allow_credentials=true` is incompatible with wildcard headers (browsers reject
470/// the preflight). Logs a warning and falls back to default headers in that case.
471fn apply_cors_headers_policy(
472 layer: tower_http::cors::CorsLayer,
473 cors: &CorsConfig,
474) -> tower_http::cors::CorsLayer {
475 use tower_http::cors::Any;
476
477 let has_wildcard = cors.allowed_headers.iter().any(|h| h == "*");
478 if has_wildcard && !cors.allow_credentials {
479 return layer.allow_headers(Any);
480 }
481 if has_wildcard && cors.allow_credentials {
482 tracing::warn!(
483 "CORS: allow_credentials=true is incompatible with wildcard \
484 headers per CORS spec. Falling back to default headers \
485 (Content-Type, Authorization)."
486 );
487 }
488 let headers: Vec<axum::http::HeaderName> = cors
489 .allowed_headers
490 .iter()
491 .filter(|h| h.as_str() != "*")
492 .filter_map(|h| h.parse().ok())
493 .collect();
494 if headers.is_empty() && cors.allow_credentials {
495 layer.allow_headers([
496 axum::http::header::CONTENT_TYPE,
497 axum::http::header::AUTHORIZATION,
498 ])
499 } else {
500 layer.allow_headers(headers)
501 }
502}
503
504// ============================================================================
505// Helper: parse comma-separated API keys from env var
506// ============================================================================
507
508/// Parse `VELESDB_API_KEYS` env var (comma-separated) into a `Vec<String>`.
509pub fn parse_api_keys_env() -> Option<Vec<String>> {
510 let val = std::env::var("VELESDB_API_KEYS").ok()?;
511 let keys: Vec<String> = val
512 .split(',')
513 .map(|s| s.trim().to_string())
514 .filter(|s| !s.is_empty())
515 .collect();
516 if keys.is_empty() {
517 None
518 } else {
519 Some(keys)
520 }
521}
522
523// ============================================================================
524// Tests
525// ============================================================================
526
527#[cfg(test)]
528#[path = "config_tests.rs"]
529mod tests;