use crate::error::{FusekiError, FusekiResult};
use figment::{
providers::{Env, Format, Toml, Yaml},
Figment,
};
#[cfg(feature = "hot-reload")]
use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
#[cfg(feature = "hot-reload")]
use std::sync::mpsc;
use std::time::Duration;
#[cfg(feature = "hot-reload")]
use tokio::sync::watch;
use tracing::{info, warn};
use validator::{Validate, ValidationError};
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct ServerConfig {
#[validate(nested)]
pub server: ServerSettings,
#[validate(nested)]
pub datasets: HashMap<String, DatasetConfig>,
#[validate(nested)]
pub security: SecurityConfig,
#[validate(nested)]
pub monitoring: MonitoringConfig,
#[validate(nested)]
pub performance: PerformanceConfig,
#[validate(nested)]
pub logging: LoggingConfig,
#[serde(skip)]
pub federation: Option<crate::federation::FederationConfig>,
#[serde(skip)]
pub streaming: Option<crate::streaming::StreamingConfig>,
#[validate(nested)]
pub http_protocol: HttpProtocolSettings,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct ServerSettings {
#[validate(range(min = 1, max = 65535))]
pub port: u16,
#[validate(length(min = 1))]
pub host: String,
pub admin_ui: bool,
pub cors: bool,
#[validate(range(min = 1))]
pub max_connections: usize,
#[validate(range(min = 1))]
pub request_timeout_secs: u64,
#[validate(range(min = 1))]
pub graceful_shutdown_timeout_secs: u64,
pub tls: Option<TlsConfig>,
#[serde(default)]
pub backup_directory: Option<PathBuf>,
#[serde(skip)]
pub config_file: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct TlsConfig {
#[validate(custom(function = "validate_path"))]
pub cert_path: PathBuf,
#[validate(custom(function = "validate_path"))]
pub key_path: PathBuf,
pub require_client_cert: bool,
pub ca_cert_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct HttpProtocolSettings {
#[serde(default = "default_true")]
pub http2_enabled: bool,
#[serde(default)]
pub http3_enabled: bool,
#[serde(default = "default_http2_connection_window")]
#[validate(range(min = 65535, max = 16777216))]
pub http2_initial_connection_window_size: u32,
#[serde(default = "default_http2_stream_window")]
#[validate(range(min = 65535, max = 16777216))]
pub http2_initial_stream_window_size: u32,
#[serde(default = "default_http2_max_streams")]
#[validate(range(min = 1, max = 1000))]
pub http2_max_concurrent_streams: u32,
#[serde(default = "default_http2_frame_size")]
#[validate(range(min = 16384, max = 16777215))]
pub http2_max_frame_size: u32,
#[serde(default = "default_http2_keepalive")]
#[validate(range(min = 1))]
pub http2_keep_alive_interval_secs: u64,
#[serde(default = "default_http2_keepalive_timeout")]
#[validate(range(min = 1))]
pub http2_keep_alive_timeout_secs: u64,
#[serde(default)]
pub enable_server_push: bool,
#[serde(default = "default_true")]
pub enable_header_compression: bool,
#[serde(default = "default_true")]
pub sparql_optimized: bool,
}
fn default_true() -> bool {
true
}
fn default_http2_connection_window() -> u32 {
1024 * 1024 }
fn default_http2_stream_window() -> u32 {
256 * 1024 }
fn default_http2_max_streams() -> u32 {
100
}
fn default_http2_frame_size() -> u32 {
16384 }
fn default_http2_keepalive() -> u64 {
60 }
fn default_http2_keepalive_timeout() -> u64 {
20 }
impl Default for HttpProtocolSettings {
fn default() -> Self {
Self {
http2_enabled: true,
http3_enabled: false,
http2_initial_connection_window_size: default_http2_connection_window(),
http2_initial_stream_window_size: default_http2_stream_window(),
http2_max_concurrent_streams: default_http2_max_streams(),
http2_max_frame_size: default_http2_frame_size(),
http2_keep_alive_interval_secs: default_http2_keepalive(),
http2_keep_alive_timeout_secs: default_http2_keepalive_timeout(),
enable_server_push: false,
enable_header_compression: true,
sparql_optimized: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct DatasetConfig {
#[validate(length(min = 1))]
pub name: String,
#[validate(length(min = 1))]
pub location: String,
pub read_only: bool,
#[validate(nested)]
pub text_index: Option<TextIndexConfig>,
pub shacl_shapes: Vec<PathBuf>,
#[validate(nested)]
pub services: Vec<ServiceConfig>,
#[validate(nested)]
pub access_control: Option<AccessControlConfig>,
pub backup: Option<BackupConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct ServiceConfig {
#[validate(length(min = 1))]
pub name: String,
pub service_type: ServiceType,
#[validate(length(min = 1))]
pub endpoint: String,
pub auth_required: bool,
pub rate_limit: Option<RateLimitConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ServiceType {
SparqlQuery,
SparqlUpdate,
GraphStore,
GraphQL,
Rest,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct AccessControlConfig {
pub read_roles: Vec<String>,
pub write_roles: Vec<String>,
pub admin_roles: Vec<String>,
pub public_read: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct BackupConfig {
pub enabled: bool,
pub directory: PathBuf,
#[validate(range(min = 1))]
pub interval_hours: u64,
#[validate(range(min = 1))]
pub retain_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct TextIndexConfig {
pub enabled: bool,
#[validate(length(min = 1))]
pub analyzer: String,
#[validate(range(min = 1))]
pub max_results: usize,
pub stemming: bool,
pub stop_words: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate, Default)]
pub struct SecurityConfig {
pub auth_required: bool,
#[validate(nested)]
pub users: HashMap<String, UserConfig>,
#[validate(nested)]
pub jwt: Option<JwtConfig>,
#[validate(nested)]
pub oauth: Option<OAuthConfig>,
#[validate(nested)]
pub ldap: Option<LdapConfig>,
#[validate(nested)]
pub rate_limiting: Option<RateLimitConfig>,
pub cors: CorsConfig,
pub session: SessionConfig,
#[validate(nested)]
pub authentication: AuthenticationConfig,
#[validate(nested)]
pub api_keys: Option<ApiKeyConfig>,
#[validate(nested)]
pub certificate: Option<CertificateConfig>,
#[validate(nested)]
pub saml: Option<SamlConfig>,
#[validate(nested)]
pub rebac: Option<RebacConfig>,
#[validate(nested)]
pub mfa: Option<MfaConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate, Default)]
pub struct AuthenticationConfig {
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct ApiKeyConfig {
pub enabled: bool,
#[validate(range(min = 1, max = 3650))] pub default_expiration_days: u32,
#[validate(range(min = 1, max = 100))]
pub max_keys_per_user: u32,
pub default_rate_limit: Option<ApiKeyRateLimit>,
pub usage_analytics: bool,
pub storage: ApiKeyStorageConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct ApiKeyRateLimit {
#[validate(range(min = 1))]
pub requests_per_minute: u32,
#[validate(range(min = 1))]
pub requests_per_hour: u32,
#[validate(range(min = 1))]
pub requests_per_day: u32,
#[validate(range(min = 1))]
pub burst_limit: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct ApiKeyStorageConfig {
pub backend: ApiKeyStorageBackend,
#[validate(length(min = 1))]
pub connection: String,
#[validate(length(min = 32))]
pub encryption_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApiKeyStorageBackend {
Memory,
File,
Sqlite,
Postgres,
Redis,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct CertificateConfig {
pub enabled: bool,
pub require_client_cert: bool,
pub trust_store: Vec<PathBuf>,
pub crl_sources: Vec<String>,
pub check_crl: bool,
pub check_ocsp: bool,
pub allow_self_signed: bool,
pub user_mapping: CertificateUserMapping,
#[validate(range(min = 1, max = 10))]
pub max_chain_length: u8,
pub validation_level: CertificateValidationLevel,
pub trusted_issuers: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct CertificateUserMapping {
pub username_source: CertificateUsernameSource,
pub dn_mapping_rules: Vec<DnMappingRule>,
pub default_roles: Vec<String>,
pub ou_role_mapping: HashMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CertificateUsernameSource {
CommonName,
SubjectDn,
EmailSan,
CustomPattern(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct DnMappingRule {
#[validate(length(min = 1))]
pub pattern: String,
#[validate(length(min = 1))]
pub replacement: String,
pub roles: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CertificateValidationLevel {
Strict,
Moderate,
Permissive,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct SamlConfig {
pub enabled: bool,
#[validate(length(min = 1))]
pub sp_entity_id: String,
pub sp_cert_path: Option<PathBuf>,
pub sp_key_path: Option<PathBuf>,
pub idp: SamlIdpConfig,
#[validate(length(min = 1))]
pub acs_url: String,
pub slo_url: Option<String>,
pub attribute_mappings: SamlAttributeMappings,
#[validate(range(min = 300, max = 86400))]
pub session_timeout_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct SamlIdpConfig {
#[validate(length(min = 1))]
pub entity_id: String,
#[validate(length(min = 1))]
pub sso_url: String,
pub slo_url: Option<String>,
#[validate(custom(function = "validate_path"))]
pub cert_path: PathBuf,
pub metadata_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct SamlAttributeMappings {
#[validate(length(min = 1))]
pub username_attribute: String,
pub email_attribute: Option<String>,
pub name_attribute: Option<String>,
pub groups_attribute: Option<String>,
pub group_role_mapping: HashMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct RebacConfig {
pub enabled: bool,
pub policy_mode: RebacPolicyMode,
pub storage: RebacStorageBackend,
pub openfga: Option<OpenFgaConfig>,
pub initial_relationships: Vec<RelationshipTupleConfig>,
pub audit_enabled: bool,
#[validate(range(min = 0, max = 3600))]
pub cache_ttl_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RebacPolicyMode {
RbacOnly,
RebacOnly,
#[default]
Combined,
Both,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RebacStorageBackend {
#[default]
Memory,
OpenFga,
Rdf,
Database,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct OpenFgaConfig {
#[validate(length(min = 1))]
pub api_url: String,
#[validate(length(min = 1))]
pub store_id: String,
pub model_id: Option<String>,
pub api_token: Option<String>,
pub tls_enabled: bool,
#[validate(range(min = 1, max = 300))]
pub timeout_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct RelationshipTupleConfig {
#[validate(length(min = 1))]
pub subject: String,
#[validate(length(min = 1))]
pub relation: String,
#[validate(length(min = 1))]
pub object: String,
pub condition: Option<RelationshipConditionConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RelationshipConditionConfig {
TimeWindow {
not_before: Option<String>, not_after: Option<String>, },
IpAddress { allowed_ips: Vec<String> },
Attribute { key: String, value: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct MfaConfig {
pub enabled: bool,
pub required: bool,
pub methods: MfaMethods,
#[validate(nested)]
pub totp: Option<TotpConfig>,
#[validate(nested)]
pub sms: Option<SmsConfig>,
#[validate(nested)]
pub email: Option<EmailConfig>,
#[validate(nested)]
pub webauthn: Option<WebAuthnConfig>,
pub backup_codes: BackupCodesConfig,
#[validate(range(min = 300, max = 86400))] pub session_duration_secs: u64,
pub storage_path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MfaMethods {
pub totp: bool,
pub sms: bool,
pub email: bool,
pub webauthn: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct TotpConfig {
#[validate(length(min = 1))]
pub issuer: String,
#[validate(range(min = 6, max = 8))]
pub digits: u32,
#[validate(range(min = 15, max = 60))]
pub time_step_secs: u32,
#[validate(range(min = 0, max = 5))]
pub skew: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct SmsConfig {
pub provider: SmsProvider,
pub api_key: String,
pub sender: String,
#[validate(range(min = 60, max = 600))]
pub code_expiration_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SmsProvider {
Twilio,
Aws,
Custom,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct EmailConfig {
#[validate(length(min = 1))]
pub smtp_server: String,
#[validate(range(min = 1, max = 65535))]
pub smtp_port: u16,
#[validate(email)]
pub from_address: String,
pub smtp_username: Option<String>,
pub smtp_password: Option<String>,
pub use_tls: bool,
#[validate(range(min = 60, max = 600))]
pub code_expiration_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct WebAuthnConfig {
#[validate(length(min = 1))]
pub rp_id: String,
#[validate(length(min = 1))]
pub rp_name: String,
#[validate(url)]
pub origin: String,
pub require_resident_key: bool,
pub user_verification: UserVerificationRequirement,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UserVerificationRequirement {
Required,
Preferred,
Discouraged,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct BackupCodesConfig {
pub enabled: bool,
#[validate(range(min = 5, max = 20))]
pub count: u32,
#[validate(range(min = 8, max = 16))]
pub length: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct JwtConfig {
#[validate(length(min = 32))]
pub secret: String,
#[validate(range(min = 300, max = 86400))] pub expiration_secs: u64,
#[validate(length(min = 1))]
pub issuer: String,
#[validate(length(min = 1))]
pub audience: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct OAuthConfig {
#[validate(length(min = 1))]
pub provider: String,
#[validate(length(min = 1))]
pub client_id: String,
#[validate(length(min = 1))]
pub client_secret: String,
#[validate(url)]
pub auth_url: String,
#[validate(url)]
pub token_url: String,
#[validate(url)]
pub user_info_url: String,
pub scopes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct LdapConfig {
#[validate(url)]
pub server: String,
#[validate(length(min = 1))]
pub bind_dn: String,
#[validate(length(min = 1))]
pub bind_password: String,
#[validate(length(min = 1))]
pub user_base_dn: String,
#[validate(length(min = 1))]
pub user_filter: String,
#[validate(length(min = 1))]
pub group_base_dn: String,
#[validate(length(min = 1))]
pub group_filter: String,
pub use_tls: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct RateLimitConfig {
#[validate(range(min = 1))]
pub requests_per_minute: u32,
#[validate(range(min = 1))]
pub burst_size: u32,
pub per_ip: bool,
pub per_user: bool,
pub whitelist: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct CorsConfig {
pub enabled: bool,
pub allow_origins: Vec<String>,
pub allow_methods: Vec<String>,
pub allow_headers: Vec<String>,
pub expose_headers: Vec<String>,
pub allow_credentials: bool,
#[validate(range(min = 0, max = 86400))]
pub max_age_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct SessionConfig {
#[validate(length(min = 32))]
pub secret: String,
#[validate(range(min = 300, max = 86400))] pub timeout_secs: u64,
pub secure: bool,
pub http_only: bool,
pub same_site: SameSitePolicy,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SameSitePolicy {
Strict,
Lax,
None,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct UserConfig {
#[validate(length(min = 1))]
pub password_hash: String,
pub roles: Vec<String>,
pub permissions: Vec<crate::auth::types::Permission>,
pub enabled: bool,
pub email: Option<String>,
pub full_name: Option<String>,
pub last_login: Option<chrono::DateTime<chrono::Utc>>,
pub failed_login_attempts: u32,
pub locked_until: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate, Default)]
pub struct MonitoringConfig {
pub metrics: MetricsConfig,
pub health_checks: HealthCheckConfig,
pub tracing: TracingConfig,
pub prometheus: Option<PrometheusConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct MetricsConfig {
pub enabled: bool,
#[validate(length(min = 1))]
pub endpoint: String,
#[validate(range(min = 1, max = 65535))]
pub port: Option<u16>,
pub namespace: String,
pub collect_system_metrics: bool,
pub histogram_buckets: Vec<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct PrometheusConfig {
pub enabled: bool,
#[validate(length(min = 1))]
pub endpoint: String,
#[validate(range(min = 1, max = 65535))]
pub port: Option<u16>,
pub namespace: String,
pub job_name: String,
pub instance: String,
pub scrape_interval_secs: u64,
pub timeout_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct HealthCheckConfig {
pub enabled: bool,
#[validate(range(min = 1))]
pub interval_secs: u64,
#[validate(range(min = 1))]
pub timeout_secs: u64,
pub checks: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct TracingConfig {
pub enabled: bool,
pub endpoint: Option<String>,
pub service_name: String,
pub sample_rate: f64,
pub output: TracingOutput,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TracingOutput {
Stdout,
Stderr,
File,
Jaeger,
Otlp,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct PerformanceConfig {
pub caching: CacheConfig,
pub connection_pool: ConnectionPoolConfig,
pub query_optimization: QueryOptimizationConfig,
#[validate(nested)]
pub rate_limiting: Option<RateLimitConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct CacheConfig {
pub enabled: bool,
#[validate(range(min = 1))]
pub max_size: usize,
#[validate(range(min = 1))]
pub ttl_secs: u64,
pub query_cache_enabled: bool,
pub result_cache_enabled: bool,
pub plan_cache_enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct ConnectionPoolConfig {
#[validate(range(min = 1))]
pub min_connections: usize,
#[validate(range(min = 1))]
pub max_connections: usize,
#[validate(range(min = 1))]
pub connection_timeout_secs: u64,
#[validate(range(min = 1))]
pub idle_timeout_secs: u64,
#[validate(range(min = 1))]
pub max_lifetime_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct QueryOptimizationConfig {
pub enabled: bool,
#[validate(range(min = 1))]
pub max_query_time_secs: u64,
#[validate(range(min = 1))]
pub max_result_size: usize,
pub parallel_execution: bool,
#[validate(range(min = 1))]
pub thread_pool_size: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct LoggingConfig {
#[validate(length(min = 1))]
pub level: String,
pub format: LogFormat,
pub output: LogOutput,
pub file_config: Option<FileLogConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LogFormat {
Text,
Json,
Compact,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LogOutput {
Stdout,
Stderr,
File,
Both,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct FileLogConfig {
pub path: PathBuf,
#[validate(range(min = 1))]
pub max_size_mb: u64,
#[validate(range(min = 1))]
pub max_files: usize,
pub compress: bool,
}
impl Default for ServerConfig {
fn default() -> Self {
ServerConfig {
server: ServerSettings {
port: 3030,
host: "localhost".to_string(),
admin_ui: true,
cors: true,
max_connections: 1000,
request_timeout_secs: 30,
graceful_shutdown_timeout_secs: 30,
tls: None,
backup_directory: None,
config_file: None,
},
datasets: HashMap::new(),
security: SecurityConfig {
auth_required: false,
users: HashMap::new(),
jwt: None,
oauth: None,
ldap: None,
rate_limiting: None,
cors: CorsConfig {
enabled: true,
allow_origins: vec!["*".to_string()],
allow_methods: vec![
"GET".to_string(),
"POST".to_string(),
"PUT".to_string(),
"DELETE".to_string(),
],
allow_headers: vec!["*".to_string()],
expose_headers: vec![],
allow_credentials: false,
max_age_secs: 3600,
},
session: SessionConfig {
secret: uuid::Uuid::new_v4().to_string(),
timeout_secs: 3600,
secure: false,
http_only: true,
same_site: SameSitePolicy::Lax,
},
authentication: AuthenticationConfig { enabled: false },
api_keys: None,
certificate: None,
saml: None,
rebac: None,
mfa: None,
},
monitoring: MonitoringConfig {
metrics: MetricsConfig {
enabled: true,
endpoint: "/metrics".to_string(),
port: None,
namespace: "oxirs_fuseki".to_string(),
collect_system_metrics: true,
histogram_buckets: vec![
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
],
},
health_checks: HealthCheckConfig {
enabled: true,
interval_secs: 30,
timeout_secs: 5,
checks: vec!["store".to_string(), "memory".to_string()],
},
tracing: TracingConfig {
enabled: false,
endpoint: None,
service_name: "oxirs-fuseki".to_string(),
sample_rate: 0.1,
output: TracingOutput::Stdout,
},
prometheus: None,
},
performance: PerformanceConfig {
caching: CacheConfig {
enabled: true,
max_size: 1000,
ttl_secs: 300,
query_cache_enabled: true,
result_cache_enabled: true,
plan_cache_enabled: true,
},
connection_pool: ConnectionPoolConfig {
min_connections: 1,
max_connections: 10,
connection_timeout_secs: 30,
idle_timeout_secs: 600,
max_lifetime_secs: 3600,
},
query_optimization: QueryOptimizationConfig {
enabled: true,
max_query_time_secs: 300,
max_result_size: 1_000_000,
parallel_execution: true,
thread_pool_size: get_cpu_count(),
},
rate_limiting: None,
},
logging: LoggingConfig {
level: "info".to_string(),
format: LogFormat::Text,
output: LogOutput::Stdout,
file_config: None,
},
federation: None,
streaming: None,
http_protocol: HttpProtocolSettings::default(),
}
}
}
impl ServerConfig {
pub fn load() -> FusekiResult<Self> {
let config: Self = Figment::new()
.merge(Toml::file("oxirs-fuseki.toml"))
.merge(Yaml::file("oxirs-fuseki.yaml"))
.merge(Yaml::file("oxirs-fuseki.yml"))
.merge(Env::prefixed("OXIRS_FUSEKI_"))
.extract()
.map_err(|e| {
FusekiError::configuration(format!("Failed to load configuration: {e}"))
})?;
config.validate().map_err(|e| {
FusekiError::validation(format!("Configuration validation failed: {e}"))
})?;
Ok(config)
}
pub fn from_file<P: AsRef<Path>>(path: P) -> FusekiResult<Self> {
let path = path.as_ref();
let config: Self = match path.extension().and_then(|ext| ext.to_str()) {
Some("toml") => {
let figment = Figment::new()
.merge(Toml::file(path))
.merge(Env::prefixed("OXIRS_FUSEKI_"));
figment.extract()
}
Some("yaml") | Some("yml") => {
let figment = Figment::new()
.merge(Yaml::file(path))
.merge(Env::prefixed("OXIRS_FUSEKI_"));
figment.extract()
}
_ => {
return Err(FusekiError::configuration(format!(
"Unsupported configuration file format: {path:?}"
)));
}
}
.map_err(|e| {
FusekiError::configuration(format!("Failed to load configuration from {path:?}: {e}"))
})?;
config.validate().map_err(|e| {
FusekiError::validation(format!("Configuration validation failed: {e}"))
})?;
info!("Configuration loaded from {:?}", path);
Ok(config)
}
pub fn save_yaml<P: AsRef<Path>>(&self, path: P) -> FusekiResult<()> {
let content = serde_yaml::to_string(self).map_err(|e| {
FusekiError::configuration(format!("Failed to serialize configuration to YAML: {e}"))
})?;
std::fs::write(&path, content).map_err(|e| {
FusekiError::configuration(format!(
"Failed to write configuration to {:?}: {}",
path.as_ref(),
e
))
})?;
info!("Configuration saved to {:?}", path.as_ref());
Ok(())
}
pub fn save_toml<P: AsRef<Path>>(&self, path: P) -> FusekiResult<()> {
let content = toml::to_string_pretty(self).map_err(|e| {
FusekiError::configuration(format!("Failed to serialize configuration to TOML: {e}"))
})?;
std::fs::write(&path, content).map_err(|e| {
FusekiError::configuration(format!(
"Failed to write configuration to {:?}: {}",
path.as_ref(),
e
))
})?;
info!("Configuration saved to {:?}", path.as_ref());
Ok(())
}
pub fn socket_addr(&self) -> FusekiResult<SocketAddr> {
use std::net::ToSocketAddrs;
let addr = format!("{}:{}", self.server.host, self.server.port);
let socket_addrs: Vec<SocketAddr> = addr
.to_socket_addrs()
.map_err(|e| {
FusekiError::configuration(format!("Invalid host:port combination '{addr}': {e}"))
})?
.collect();
socket_addrs.into_iter().next().ok_or_else(|| {
FusekiError::configuration(format!("No valid socket address found for '{addr}'"))
})
}
pub fn request_timeout(&self) -> Duration {
Duration::from_secs(self.server.request_timeout_secs)
}
pub fn graceful_shutdown_timeout(&self) -> Duration {
Duration::from_secs(self.server.graceful_shutdown_timeout_secs)
}
pub fn is_tls_enabled(&self) -> bool {
self.server.tls.is_some()
}
pub fn requires_auth(&self) -> bool {
self.security.auth_required
}
pub fn metrics_enabled(&self) -> bool {
self.monitoring.metrics.enabled
}
pub fn tracing_enabled(&self) -> bool {
self.monitoring.tracing.enabled
}
pub fn validate_detailed(&self) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
if self.server.port < 1024 && !is_privileged_user() {
errors.push(format!(
"Port {} requires elevated privileges. Consider using port >= 1024",
self.server.port
));
}
if let Some(ref tls) = self.server.tls {
if !tls.cert_path.exists() {
errors.push(format!(
"TLS certificate file not found: {:?}",
tls.cert_path
));
}
if !tls.key_path.exists() {
errors.push(format!("TLS key file not found: {:?}", tls.key_path));
}
}
for (name, dataset) in &self.datasets {
if dataset.location.is_empty() {
errors.push(format!("Dataset '{name}' has empty location"));
}
for shape_file in &dataset.shacl_shapes {
if !shape_file.exists() {
errors.push(format!(
"SHACL shape file not found for dataset '{name}': {shape_file:?}"
));
}
}
}
if let Some(ref jwt) = self.security.jwt {
if jwt.secret.len() < 32 {
errors.push("JWT secret must be at least 32 characters long".to_string());
}
}
if let Some(ref file_config) = self.logging.file_config {
if let Some(parent) = file_config.path.parent() {
if !parent.exists() {
errors.push(format!("Log file directory does not exist: {parent:?}"));
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
impl Default for MetricsConfig {
fn default() -> Self {
Self {
enabled: false,
endpoint: "/metrics".to_string(),
port: None,
namespace: "oxirs".to_string(),
collect_system_metrics: true,
histogram_buckets: vec![0.001, 0.01, 0.1, 1.0, 10.0],
}
}
}
impl Default for HealthCheckConfig {
fn default() -> Self {
Self {
enabled: true,
interval_secs: 30,
timeout_secs: 5,
checks: vec!["database".to_string(), "memory".to_string()],
}
}
}
impl Default for TracingConfig {
fn default() -> Self {
Self {
enabled: false,
endpoint: None,
service_name: "oxirs-fuseki".to_string(),
sample_rate: 1.0,
output: TracingOutput::Stdout,
}
}
}
impl Default for CorsConfig {
fn default() -> Self {
Self {
enabled: true,
allow_origins: vec!["*".to_string()],
allow_methods: vec!["GET".to_string(), "POST".to_string(), "OPTIONS".to_string()],
allow_headers: vec!["Content-Type".to_string(), "Authorization".to_string()],
expose_headers: vec![],
allow_credentials: false,
max_age_secs: 3600,
}
}
}
impl Default for SessionConfig {
fn default() -> Self {
Self {
secret: "default-session-secret-change-in-production".to_string(),
timeout_secs: 3600,
secure: false,
http_only: true,
same_site: SameSitePolicy::Lax,
}
}
}
#[cfg(feature = "hot-reload")]
pub struct ConfigWatcher {
_watcher: RecommendedWatcher,
receiver: tokio::sync::watch::Receiver<ServerConfig>,
}
#[cfg(feature = "hot-reload")]
impl ConfigWatcher {
pub fn new<P: AsRef<Path>>(
config_path: P,
) -> FusekiResult<(Self, tokio::sync::watch::Receiver<ServerConfig>)> {
let config_path = config_path.as_ref().to_path_buf();
let initial_config = ServerConfig::from_file(&config_path)?;
let (tx, rx) = tokio::sync::watch::channel(initial_config);
let (file_tx, file_rx) = mpsc::channel();
let mut watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
match res {
Ok(event) => {
if let Err(e) = file_tx.send(event) {
warn!("Failed to send file watch event: {}", e);
}
}
Err(e) => warn!("File watch error: {}", e),
}
})
.map_err(|e| FusekiError::configuration(format!("Failed to create file watcher: {}", e)))?;
watcher
.watch(&config_path, RecursiveMode::NonRecursive)
.map_err(|e| {
FusekiError::configuration(format!(
"Failed to watch config file {:?}: {}",
config_path, e
))
})?;
let config_path_clone = config_path.clone();
let tx_clone = tx.clone();
tokio::spawn(async move {
while let Ok(event) = file_rx.recv() {
if event.kind.is_modify() {
tokio::time::sleep(Duration::from_millis(100)).await;
match ServerConfig::from_file(&config_path_clone) {
Ok(new_config) => {
if let Err(e) = tx_clone.send(new_config) {
warn!("Failed to send updated config: {}", e);
} else {
info!("Configuration reloaded from {:?}", config_path_clone);
}
}
Err(e) => {
warn!("Failed to reload configuration: {}", e);
}
}
}
}
});
let config_watcher = ConfigWatcher {
_watcher: watcher,
receiver: rx.clone(),
};
Ok((config_watcher, rx))
}
pub fn current_config(&self) -> ServerConfig {
self.receiver.borrow().clone()
}
}
fn is_privileged_user() -> bool {
#[cfg(unix)]
{
std::env::var("USER")
.map(|user| user == "root")
.unwrap_or(false)
}
#[cfg(not(unix))]
{
false
}
}
fn get_cpu_count() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4) }
fn validate_path(path: &Path) -> Result<(), ValidationError> {
if path.as_os_str().is_empty() {
return Err(ValidationError::new("path_empty"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn test_server_config_default() {
let config = ServerConfig::default();
assert_eq!(config.server.port, 3030);
assert_eq!(config.server.host, "localhost");
assert!(config.server.admin_ui);
assert!(config.server.cors);
assert!(!config.security.auth_required);
assert!(config.datasets.is_empty());
assert!(config.security.users.is_empty());
assert!(config.monitoring.metrics.enabled);
assert!(config.performance.caching.enabled);
}
#[test]
fn test_config_validation() {
let mut config = ServerConfig::default();
assert!(config.validate().is_ok());
config.server.port = 0;
assert!(config.validate().is_err());
config.server.port = 3030;
config.server.host = String::new();
assert!(config.validate().is_err());
}
#[test]
fn test_socket_addr() {
let config = ServerConfig::default();
let addr = config.socket_addr().unwrap();
assert_eq!(addr.port(), 3030);
}
#[test]
fn test_timeouts() {
let config = ServerConfig::default();
assert_eq!(config.request_timeout().as_secs(), 30);
assert_eq!(config.graceful_shutdown_timeout().as_secs(), 30);
}
#[test]
fn test_tls_config() {
let mut config = ServerConfig::default();
assert!(!config.is_tls_enabled());
config.server.tls = Some(TlsConfig {
cert_path: "/path/to/cert.pem".into(),
key_path: "/path/to/key.pem".into(),
require_client_cert: false,
ca_cert_path: None,
});
assert!(config.is_tls_enabled());
}
#[test]
fn test_jwt_config_validation() {
let mut jwt_config = JwtConfig {
secret: "short".to_string(),
expiration_secs: 3600,
issuer: "oxirs-fuseki".to_string(),
audience: "oxirs-users".to_string(),
};
assert!(jwt_config.validate().is_err());
jwt_config.secret = "a".repeat(32);
assert!(jwt_config.validate().is_ok());
}
#[test]
fn test_rate_limit_config() {
let rate_limit = RateLimitConfig {
requests_per_minute: 100,
burst_size: 10,
per_ip: true,
per_user: false,
whitelist: vec!["127.0.0.1".to_string()],
};
assert!(rate_limit.validate().is_ok());
}
#[test]
fn test_service_types() {
let service = ServiceConfig {
name: "query".to_string(),
service_type: ServiceType::SparqlQuery,
endpoint: "sparql".to_string(),
auth_required: false,
rate_limit: None,
};
assert!(service.validate().is_ok());
}
#[test]
fn test_monitoring_config() {
let monitoring = MonitoringConfig {
metrics: MetricsConfig {
enabled: true,
endpoint: "/metrics".to_string(),
port: Some(9090),
namespace: "test".to_string(),
collect_system_metrics: true,
histogram_buckets: vec![0.1, 1.0, 10.0],
},
health_checks: HealthCheckConfig {
enabled: true,
interval_secs: 30,
timeout_secs: 5,
checks: vec!["store".to_string()],
},
tracing: TracingConfig {
enabled: false,
endpoint: None,
service_name: "test".to_string(),
sample_rate: 0.1,
output: TracingOutput::Stdout,
},
prometheus: None,
};
assert!(monitoring.validate().is_ok());
}
#[test]
fn test_performance_config() {
let performance = PerformanceConfig {
caching: CacheConfig {
enabled: true,
max_size: 1000,
ttl_secs: 300,
query_cache_enabled: true,
result_cache_enabled: true,
plan_cache_enabled: true,
},
connection_pool: ConnectionPoolConfig {
min_connections: 1,
max_connections: 10,
connection_timeout_secs: 30,
idle_timeout_secs: 600,
max_lifetime_secs: 3600,
},
query_optimization: QueryOptimizationConfig {
enabled: true,
max_query_time_secs: 300,
max_result_size: 1_000_000,
parallel_execution: true,
thread_pool_size: 4,
},
rate_limiting: None,
};
assert!(performance.validate().is_ok());
}
#[test]
fn test_logging_config() {
let logging = LoggingConfig {
level: "info".to_string(),
format: LogFormat::Json,
output: LogOutput::Stdout,
file_config: None,
};
assert!(logging.validate().is_ok());
}
#[test]
fn test_user_config_extended() {
let user = UserConfig {
password_hash: "$argon2id$v=19$m=65536,t=3,p=4$...".to_string(),
roles: vec!["admin".to_string(), "user".to_string()],
permissions: vec![],
enabled: true,
email: Some("admin@example.com".to_string()),
full_name: Some("Administrator".to_string()),
last_login: None,
failed_login_attempts: 0,
locked_until: None,
};
assert!(user.validate().is_ok());
assert_eq!(user.roles.len(), 2);
assert!(user.enabled);
assert_eq!(user.failed_login_attempts, 0);
}
#[test]
fn test_cors_config() {
let cors = CorsConfig {
enabled: true,
allow_origins: vec!["http://localhost:3000".to_string()],
allow_methods: vec!["GET".to_string(), "POST".to_string()],
allow_headers: vec!["Content-Type".to_string()],
expose_headers: vec![],
allow_credentials: true,
max_age_secs: 3600,
};
assert!(cors.validate().is_ok());
}
#[test]
fn test_session_config() {
let session = SessionConfig {
secret: "a".repeat(32),
timeout_secs: 3600,
secure: true,
http_only: true,
same_site: SameSitePolicy::Strict,
};
assert!(session.validate().is_ok());
}
#[test]
fn test_save_and_load_yaml() {
let config = ServerConfig::default();
let temp_file = NamedTempFile::new().unwrap();
let temp_path = temp_file.path().with_extension("yaml");
config.save_yaml(&temp_path).unwrap();
let loaded_config = ServerConfig::from_file(&temp_path).unwrap();
assert_eq!(config.server.port, loaded_config.server.port);
assert_eq!(config.server.host, loaded_config.server.host);
}
#[test]
fn test_save_and_load_toml() {
let config = ServerConfig::default();
let temp_file = NamedTempFile::new().unwrap();
let temp_path = temp_file.path().with_extension("toml");
config.save_toml(&temp_path).unwrap();
let loaded_config = ServerConfig::from_file(&temp_path).unwrap();
assert_eq!(config.server.port, loaded_config.server.port);
assert_eq!(config.server.host, loaded_config.server.host);
std::fs::remove_file(temp_path).ok();
}
#[test]
fn test_detailed_validation() {
let mut config = ServerConfig::default();
assert!(config.validate_detailed().is_ok());
let dataset = DatasetConfig {
name: "test".to_string(),
location: String::new(), read_only: false,
text_index: None,
shacl_shapes: vec![],
services: vec![],
access_control: None,
backup: None,
};
config.datasets.insert("test".to_string(), dataset);
let errors = config.validate_detailed().unwrap_err();
assert!(!errors.is_empty());
assert!(errors.iter().any(|e| e.contains("empty location")));
}
}