Skip to main content

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;