use std::borrow::Cow; use std::{env, io}; use api::grpc::transport_channel_pool::{ DEFAULT_CONNECT_TIMEOUT, DEFAULT_GRPC_TIMEOUT, DEFAULT_KEEPALIVE_INTERVAL, DEFAULT_KEEPALIVE_TIMEOUT, DEFAULT_POOL_SIZE, }; use collection::common::snapshots_manager::SnapshotsStorageConfig; use collection::operations::validation; use collection::shards::shard::PeerId; use common::flags::FeatureFlags; use config::{Config, ConfigError, Environment, File, FileFormat, Source}; use serde::Deserialize; use storage::content_manager::consensus_shadow::ShadowMode; use storage::types::StorageConfig; use validator::{Validate, ValidationError}; use crate::common::audit::AuditConfig; use crate::common::debugger::DebuggerConfig; use crate::common::inference::config::InferenceConfig; use crate::tracing; const MAX_PEER_ID: u64 = (1 << 53) - 1; const DEFAULT_CONFIG: &str = include_str!("../config/config.yaml"); #[derive(Debug, Deserialize, Validate, Clone)] pub struct ServiceConfig { #[validate(length(min = 1))] pub host: String, pub http_port: u16, pub grpc_port: Option, // None means that gRPC is disabled /// If specified, qdrant will serve a separate service for `/metrics` on this port. /// Separate port is not protected by API keys and dedicated for internal monitoring systems. /// This port should not be exposed to untrusted networks. #[serde(default)] pub metrics_port: Option, pub max_request_size_mb: usize, pub max_workers: Option, /// Keep-alive timeout for incoming HTTP connections in seconds. #[serde(default = "default_http_keep_alive_timeout_sec")] #[validate(range(min = 1))] pub http_keep_alive_timeout_sec: u64, /// Timeout for reading HTTP request data from clients in seconds. #[serde(default = "default_http_client_request_timeout_sec")] #[validate(range(min = 1))] pub http_client_request_timeout_sec: u64, /// Timeout for client disconnect handling in seconds. #[serde(default = "default_http_client_disconnect_timeout_sec")] #[validate(range(min = 1))] pub http_client_disconnect_timeout_sec: u64, #[serde(default = "default_cors")] pub enable_cors: bool, #[serde(default)] pub enable_tls: bool, #[serde(default)] pub verify_https_client_certificate: bool, pub api_key: Option, /// Same as `api_key`, can be used for rolling key rotation. pub alt_api_key: Option, pub read_only_api_key: Option, #[serde(default)] pub jwt_rbac: Option, /// Enforce API key / JWT authentication on the internal (p2p) gRPC API. /// /// Enabled by default. Peers forward `api_key` (or `alt_api_key` if /// `api_key` is unset) on internal gRPC requests, and the receiving side /// accepts `api_key` or `alt_api_key`. /// Has no effect when no API key is configured. #[serde(default)] pub enforce_internal_auth: Option, #[serde(default)] pub hide_jwt_dashboard: Option, /// Directory where static files are served from. /// For example, the Web-UI should be placed here. #[serde(default)] pub static_content_dir: Option, /// If serving of the static content is enabled. /// This includes the Web-UI. True by default. #[serde(default)] pub enable_static_content: Option, /// How much time is considered too long for a query to execute. pub slow_query_secs: Option, /// Whether to enable reporting of measured hardware utilization in API responses. #[serde(default)] pub hardware_reporting: Option, /// Global prefix for metrics. #[serde(default)] #[validate(custom(function = validate_metrics_prefix))] pub metrics_prefix: Option, /// Whether to allow snapshot recovery from remote URLs (http/https). /// If disabled, snapshot recovery will only work with local files and uploads. /// Disabling this can mitigate SSRF risks in environments where the Qdrant node /// has access to internal resources that should not be reachable by users. #[serde(default = "default_snapshot_url_recovery")] pub enable_snapshot_url_recovery: bool, } impl ServiceConfig { pub fn hardware_reporting(&self) -> bool { self.hardware_reporting.unwrap_or_default() } pub fn enforce_internal_auth(&self) -> bool { self.enforce_internal_auth.unwrap_or(true) } } #[derive(Debug, Deserialize, Clone, Default, Validate)] pub struct ClusterConfig { pub enabled: bool, // disabled by default #[serde(default)] #[validate(range(min = 1, max = MAX_PEER_ID))] pub peer_id: Option, #[serde(default = "default_timeout_ms")] #[validate(range(min = 1))] pub grpc_timeout_ms: u64, #[serde(default = "default_connection_timeout_ms")] #[validate(range(min = 1))] pub connection_timeout_ms: u64, #[serde(default)] #[validate(nested)] pub p2p: P2pConfig, #[serde(default)] #[validate(nested)] pub consensus: ConsensusConfig, #[serde(default)] pub resharding_enabled: bool, // disabled by default } #[derive(Debug, Deserialize, Clone, Validate)] pub struct P2pConfig { /// Host or IP address to bind the internal (p2p) gRPC listener to. /// Defaults to `service.host` when not set. Set this to an internal /// interface to keep the internal port off the interface that serves /// the public API. #[validate(length(min = 1))] #[serde(default)] pub host: Option, #[serde(default)] pub port: Option, #[serde(default = "default_connection_pool_size")] #[validate(range(min = 1))] pub connection_pool_size: usize, #[serde(default)] pub enable_tls: bool, #[serde(default = "default_keepalive_interval_ms")] #[validate(range(min = 1))] pub keepalive_interval_ms: u64, #[serde(default = "default_keepalive_timeout_ms")] #[validate(range(min = 1))] pub keepalive_timeout_ms: u64, } impl Default for P2pConfig { fn default() -> Self { P2pConfig { host: None, port: None, connection_pool_size: default_connection_pool_size(), enable_tls: false, keepalive_interval_ms: default_keepalive_interval_ms(), keepalive_timeout_ms: default_keepalive_timeout_ms(), } } } #[derive(Debug, Deserialize, Clone, Validate)] pub struct ConsensusConfig { #[serde(default = "default_max_message_queue_size")] pub max_message_queue_size: usize, // controls the back-pressure at the Raft level #[serde(default = "default_tick_period_ms")] #[validate(range(min = 1))] pub tick_period_ms: u64, #[serde(default = "default_bootstrap_timeout_sec")] #[validate(range(min = 1))] pub bootstrap_timeout_sec: u64, #[validate(range(min = 1))] #[serde(default = "default_message_timeout_tics")] pub message_timeout_ticks: u64, /// Compact WAL when it grows to enough applied entries #[serde(default = "default_compact_wal_entries")] pub compact_wal_entries: u64, /// Compare consensus state machine with operation handlers #[serde(default)] pub shadow_state_machine: ShadowMode, } impl Default for ConsensusConfig { fn default() -> Self { ConsensusConfig { max_message_queue_size: default_max_message_queue_size(), tick_period_ms: default_tick_period_ms(), bootstrap_timeout_sec: default_bootstrap_timeout_sec(), message_timeout_ticks: default_message_timeout_tics(), compact_wal_entries: default_compact_wal_entries(), shadow_state_machine: ShadowMode::default(), } } } #[derive(Debug, Deserialize, Clone, Validate)] pub struct TlsConfig { pub cert: String, pub key: String, pub ca_cert: Option, #[serde(default = "default_tls_cert_ttl")] #[validate(range(min = 1))] pub cert_ttl: Option, } #[derive(Clone, Debug, Deserialize, Validate)] pub struct GpuConfig { /// Enable GPU indexing. #[serde(default)] pub indexing: bool, /// Force half precision for `f32` values while indexing. /// `f16` conversion will take place only inside GPU memory and won't affect storage type. #[serde(default)] pub force_half_precision: bool, /// Used vulkan "groups" of GPU. In other words, how many parallel points can be indexed by GPU. /// Optimal value might depend on the GPU model. /// Proportional, but doesn't necessary equal to the physical number of warps. /// Do not change this value unless you know what you are doing. /// Default: 512 #[serde(default)] #[validate(range(min = 1))] pub groups_count: Option, /// Filter for GPU devices by hardware name. Case insensitive. /// Comma-separated list of substrings to match against the gpu device name. /// Example: "nvidia" /// Default: "" - all devices are accepted. #[serde(default)] pub device_filter: String, /// List of explicit GPU devices to use. /// If host has multiple GPUs, this option allows to select specific devices /// by their index in the list of found devices. /// If `device_filter` is set, indexes are applied after filtering. /// By default, all devices are accepted. #[serde(default)] pub devices: Option>, /// How many parallel indexing processes are allowed to run. /// Default: 1 #[serde(default)] pub parallel_indexes: Option, /// Allow to use integrated GPUs. /// Default: false #[serde(default)] pub allow_integrated: bool, /// Allow to use emulated GPUs like LLVMpipe. Useful for CI. /// Default: false #[serde(default)] pub allow_emulated: bool, } #[derive(Debug, Deserialize, Clone, Validate)] pub struct Settings { #[serde(default)] pub log_level: Option, #[serde(default)] pub logger: tracing::LoggerConfig, #[validate(nested)] pub storage: StorageConfig, #[validate(nested)] pub service: ServiceConfig, #[serde(default)] #[validate(nested)] pub cluster: ClusterConfig, #[serde(default = "default_telemetry_disabled")] pub telemetry_disabled: bool, #[validate(nested)] pub tls: Option, #[serde(default)] pub debugger: DebuggerConfig, /// A list of messages for errors that happened during loading the configuration. We collect /// them and store them here while loading because then our logger is not configured yet. /// We therefore need to log these messages later, after the logger is ready. #[serde(default, skip)] pub load_errors: Vec, #[serde(default)] pub inference: Option, #[serde(default)] #[validate(nested)] pub gpu: Option, #[serde(default)] pub feature_flags: FeatureFlags, /// Audit logging configuration. #[serde(default)] pub audit: Option, } impl Settings { pub fn new(custom_config_path: Option) -> Result { let mut load_errors = vec![]; let config_exists = |path| File::with_name(path).collect().is_ok(); // Check if custom config file exists, report error if not if let Some(path) = &custom_config_path && !config_exists(path) { load_errors.push(LogMsg::Error(format!( "Config file via --config-path is not found: {path}" ))); } let env = env::var("RUN_MODE").unwrap_or_else(|_| "development".into()); let config_path_env = format!("config/{env}"); // Report error if main or env config files exist, report warning if not // Check if main and env configuration file load_errors.extend( ["config/config", &config_path_env] .into_iter() .filter(|path| !config_exists(path)) .map(|path| LogMsg::Warn(format!("Config file not found: {path}"))), ); // Configuration builder: define different levels of configuration files let mut config = Config::builder() // Start with compile-time base config .add_source(File::from_str(DEFAULT_CONFIG, FileFormat::Yaml)) // Merge main config: config/config .add_source(File::with_name("config/config").required(false)) // Merge env config: config/{env} // Uses RUN_MODE, defaults to 'development' .add_source(File::with_name(&config_path_env).required(false)) // Merge local config, not tracked in git: config/local .add_source(File::with_name("config/local").required(false)); #[cfg(feature = "deb")] { // Read config, installed with deb package config = config.add_source(File::with_name("/etc/qdrant/config").required(false)); } // Merge user provided config with --config-path if let Some(path) = custom_config_path { config = config.add_source(File::with_name(&path).required(false)); } // Merge environment settings // E.g.: `QDRANT_DEBUG=1 ./target/app` would set `debug=true` config = config.add_source(Environment::with_prefix("QDRANT").separator("__")); // Build and merge config and deserialize into Settings, attach any load errors we had let mut settings: Settings = config.build()?.try_deserialize()?; settings.load_errors.extend(load_errors); Ok(settings) } pub fn tls(&self) -> io::Result<&TlsConfig> { self.tls .as_ref() .ok_or_else(Self::tls_config_is_undefined_error) } pub fn tls_config_is_undefined_error() -> io::Error { io::Error::other("TLS config is not defined in the Qdrant config file") } pub fn validate_and_warn(&self) { // // API keys // // An empty key is treated as unset (see `AuthKeys::try_create`), so a key // that unexpectedly resolves to an empty string (e.g. an unset environment // variable or a secret that resolved to nothing) silently disables that // credential rather than enforcing it. Warn so the misconfiguration is visible. for (name, key) in [ ("api_key", &self.service.api_key), ("alt_api_key", &self.service.alt_api_key), ("read_only_api_key", &self.service.read_only_api_key), ] { if key.as_deref().is_some_and(str::is_empty) { log::warn!( "Service {name} is set but empty, it is treated as unset and \ authentication may be disabled", ); } } // // JWT RBAC // // Using HMAC-SHA256, recommended secret size is 32 bytes const JWT_RECOMMENDED_SECRET_LENGTH: usize = 256 / 8; let all_keys_are_empty = self .service .api_key .as_deref() .unwrap_or_default() .is_empty() && self .service .alt_api_key .as_deref() .unwrap_or_default() .is_empty(); let min_length = [ self.service.api_key.as_ref(), self.service.alt_api_key.as_ref(), ] .into_iter() .flatten() .map(|key| key.len()) .min() .unwrap_or_default(); let any_api_key_is_short = min_length < JWT_RECOMMENDED_SECRET_LENGTH; // Log if JWT RBAC is enabled but no API key is set if self.service.jwt_rbac.unwrap_or_default() { if all_keys_are_empty { log::warn!("JWT RBAC configured but no API key set, JWT RBAC is not enabled") // Log if JWT RAC is enabled, API key is set but smaller than recommended size for JWT secret } else if any_api_key_is_short { log::warn!( "It is highly recommended to use an API key of {JWT_RECOMMENDED_SECRET_LENGTH} bytes when JWT RBAC is enabled", ) } } // // Internal (p2p) auth in distributed mode // // Peers forward `api_key` (or `alt_api_key` if unset) on internal gRPC // requests, and the receiving side accepts `api_key` or `alt_api_key` // only. Warn about every configuration that leaves the internal API // open, or that enforces auth without a key for peers to send. let read_only_key_set = !self .service .read_only_api_key .as_deref() .unwrap_or_default() .is_empty(); let enforce_internal_auth = self.service.enforce_internal_auth(); if self.cluster.enabled { if all_keys_are_empty && enforce_internal_auth && read_only_key_set { log::warn!( "`service.enforce_internal_auth` is enabled with only \ `read_only_api_key` configured. The internal (p2p) gRPC API \ accepts `api_key` or `alt_api_key` only, so peers will not be \ able to reach each other. Configure `api_key`, or disable \ `enforce_internal_auth`.", ); } else if all_keys_are_empty { log::warn!( "Running in distributed mode without an API key. The internal \ (p2p) gRPC API is not authenticated, and \ `service.enforce_internal_auth` has no effect without \ `service.api_key`. Restrict the internal port to cluster \ nodes and configure an API key.", ); } else if !enforce_internal_auth { log::warn!( "Running in distributed mode with an API key configured, but \ `service.enforce_internal_auth` is disabled. The internal \ (p2p) gRPC API is not authenticated. Enable \ `enforce_internal_auth`.", ); } } // // Snapshot storage // // A config block for a backend other than the selected one is ignored, // which usually means a typo in `snapshots_storage`. let snapshots_config = &self.storage.snapshots_config; let selected = snapshots_config.snapshots_storage; for (name, present, backend) in [ ( "s3_config", snapshots_config.s3_config.is_some(), SnapshotsStorageConfig::S3, ), ( "gcs_config", snapshots_config.gcs_config.is_some(), SnapshotsStorageConfig::Gcs, ), ( "azure_config", snapshots_config.azure_config.is_some(), SnapshotsStorageConfig::Azure, ), ] { if present && selected != backend { log::warn!( "Snapshots {name} is set but snapshots_storage is {selected:?}, \ the block is ignored", ); } } // Print any load error messages we had self.load_errors.iter().for_each(LogMsg::log); if let Err(ref errs) = self.validate() { validation::warn_validation_errors("Settings configuration file", errs); } } } /// Returns the number of maximum actix workers. pub fn max_web_workers(settings: &Settings) -> usize { match settings.service.max_workers { Some(0) => { let num_cpu = common::cpu::get_num_cpus(); std::cmp::max(1, num_cpu - 1) } Some(max_workers) => max_workers, None => settings.storage.performance.max_search_threads, } } #[derive(Clone, Debug)] pub enum LogMsg { Warn(String), Error(String), } impl LogMsg { fn log(&self) { match self { Self::Warn(msg) => log::warn!("{msg}"), Self::Error(msg) => log::error!("{msg}"), } } } const fn default_telemetry_disabled() -> bool { false } const fn default_cors() -> bool { true } const fn default_snapshot_url_recovery() -> bool { true } const fn default_http_keep_alive_timeout_sec() -> u64 { 5 } const fn default_http_client_request_timeout_sec() -> u64 { 5 } const fn default_http_client_disconnect_timeout_sec() -> u64 { 5 } const fn default_timeout_ms() -> u64 { DEFAULT_GRPC_TIMEOUT.as_millis() as u64 } const fn default_connection_timeout_ms() -> u64 { DEFAULT_CONNECT_TIMEOUT.as_millis() as u64 } const fn default_tick_period_ms() -> u64 { 100 } // Should not be less than `DEFAULT_META_OP_WAIT` as bootstrapping perform sync. consensus meta operations. const fn default_bootstrap_timeout_sec() -> u64 { 15 } const fn default_max_message_queue_size() -> usize { 100 } const fn default_connection_pool_size() -> usize { DEFAULT_POOL_SIZE } const fn default_keepalive_interval_ms() -> u64 { DEFAULT_KEEPALIVE_INTERVAL.as_millis() as u64 } const fn default_keepalive_timeout_ms() -> u64 { DEFAULT_KEEPALIVE_TIMEOUT.as_millis() as u64 } const fn default_message_timeout_tics() -> u64 { 10 } const fn default_compact_wal_entries() -> u64 { 128 } #[allow(clippy::unnecessary_wraps)] // Used as serde default const fn default_tls_cert_ttl() -> Option { // Default one hour Some(3600) } /// Custom validation function for metrics prefixes. fn validate_metrics_prefix(prefix: &str) -> Result<(), ValidationError> { // Prefix is not required if prefix.is_empty() { return Ok(()); } // Only allow alphanumeric characters or '_' if !prefix .chars() .all(|c| c.is_ascii_alphanumeric() || c == '_') { return Err( ValidationError::new("invalid_metrics_prefix").with_message(Cow::Borrowed( "Metrics prefix must be of all alphanumeric characters, with an exception for '_'", )), ); } Ok(()) } #[cfg(test)] mod tests { use std::io::Write; use fs_err as fs; use sealed_test::prelude::*; use super::*; /// Ensure we can successfully deserialize into [`Settings`] with just the default configuration. #[test] fn test_default_config() { let config = Config::builder() .add_source(File::from_str(DEFAULT_CONFIG, FileFormat::Yaml)) .build() .expect("failed to build default config") .try_deserialize::() .expect("failed to deserialize default config"); assert_eq!( config.service.http_keep_alive_timeout_sec, default_http_keep_alive_timeout_sec() ); assert_eq!( config.service.http_client_request_timeout_sec, default_http_client_request_timeout_sec() ); assert_eq!( config.service.http_client_disconnect_timeout_sec, default_http_client_disconnect_timeout_sec() ); assert_eq!( config.cluster.p2p.keepalive_interval_ms, default_keepalive_interval_ms() ); assert_eq!( config.cluster.p2p.keepalive_timeout_ms, default_keepalive_timeout_ms() ); config .validate() .expect("failed to validate default config"); } #[expect( clippy::disallowed_methods, reason = "#[sealed_test] uses std::fs::copy" )] #[expect(clippy::disallowed_types, reason = "#[sealed_test] uses std::fs::File")] #[sealed_test(files = ["config/config.yaml", "config/development.yaml"])] fn test_runtime_development_config() { unsafe { env::set_var("RUN_MODE", "development") }; // `sealed_test` copies files into the same directory as the test runs in. // We need them in a subdirectory. fs::create_dir("config").expect("failed to create `config` subdirectory."); fs::copy("config.yaml", "config/config.yaml").expect("failed to copy `config.yaml`."); fs::copy("development.yaml", "config/development.yaml") .expect("failed to copy `development.yaml`."); // Read config let config = Settings::new(None).expect("failed to load development config at runtime"); // Validate config .validate() .expect("failed to validate development config at runtime"); assert!(config.load_errors.is_empty(), "must not have load errors") } /// Ensure environment variable used by consensus tests enables state machine validation #[expect(clippy::disallowed_types, reason = "#[sealed_test] uses std::fs::File")] #[sealed_test] fn shadow_state_machine_from_env() { unsafe { env::set_var("QDRANT__CLUSTER__CONSENSUS__SHADOW_STATE_MACHINE", "panic") }; let settings = Settings::new(None).expect("failed to load config"); assert_eq!( settings.cluster.consensus.shadow_state_machine, ShadowMode::Panic, ); } #[expect(clippy::disallowed_types, reason = "#[sealed_test] uses std::fs::File")] #[sealed_test] fn test_no_config_files() { let non_existing_config_path = "config/non_existing_config".to_string(); // Read config let config = Settings::new(Some(non_existing_config_path)) .expect("failed to load with non-existing runtime config"); // Validate config .validate() .expect("failed to validate with non-existing runtime config"); assert!(!config.load_errors.is_empty(), "must have load errors") } #[expect(clippy::disallowed_types, reason = "#[sealed_test] uses std::fs::File")] #[sealed_test] fn test_custom_config() { let path = "config/custom.yaml"; // Create custom config file { fs::create_dir("config").unwrap(); let mut custom = fs::File::create(path).unwrap(); write!(&mut custom, "service:\n http_port: 9999").unwrap(); custom.flush().unwrap(); } // Load settings with custom config let config = Settings::new(Some(path.into())).unwrap(); // Ensure our custom config is the most important assert_eq!(config.service.http_port, 9999); assert_eq!( config.service.http_keep_alive_timeout_sec, default_http_keep_alive_timeout_sec() ); assert_eq!( config.service.http_client_request_timeout_sec, default_http_client_request_timeout_sec() ); assert_eq!( config.service.http_client_disconnect_timeout_sec, default_http_client_disconnect_timeout_sec() ); } #[expect(clippy::disallowed_types, reason = "#[sealed_test] uses std::fs::File")] #[sealed_test] fn test_custom_http_transport_config() { let path = "config/custom_http_transport.yaml"; { fs::create_dir("config").unwrap(); let mut custom = fs::File::create(path).unwrap(); write!( &mut custom, "service:\n http_keep_alive_timeout_sec: 120\n http_client_request_timeout_sec: 45\n http_client_disconnect_timeout_sec: 60" ) .unwrap(); custom.flush().unwrap(); } let config = Settings::new(Some(path.into())).unwrap(); config .validate() .expect("custom HTTP transport timeouts must pass validation"); assert_eq!(config.service.http_keep_alive_timeout_sec, 120); assert_eq!(config.service.http_client_request_timeout_sec, 45); assert_eq!(config.service.http_client_disconnect_timeout_sec, 60); } #[expect(clippy::disallowed_types, reason = "#[sealed_test] uses std::fs::File")] #[sealed_test] fn test_invalid_http_transport_config() { let path = "config/invalid_http_transport.yaml"; { fs::create_dir("config").unwrap(); let mut custom = fs::File::create(path).unwrap(); write!( &mut custom, "service:\n http_keep_alive_timeout_sec: 0\n http_client_request_timeout_sec: 0\n http_client_disconnect_timeout_sec: 0" ) .unwrap(); custom.flush().unwrap(); } let config = Settings::new(Some(path.into())).unwrap(); assert!( config.validate().is_err(), "zero timeout values must fail validation" ); } }