#[path = "config_runtime.rs"]
pub mod config_runtime;
#[path = "config_security.rs"]
pub mod config_security;
#[path = "config_server.rs"]
pub mod config_server;
pub use config_runtime::*;
pub use config_security::*;
pub use config_server::*;
use crate::error::{FusekiError, FusekiResult};
use figment::{
providers::{Env, Format, Toml, Yaml},
Figment,
};
#[cfg(feature = "hot-reload")]
use notify::{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;
#[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,
}
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)
}
}
}
#[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) }
pub(crate) fn validate_path_pub(path: &Path) -> Result<(), validator::ValidationError> {
if path.as_os_str().is_empty() {
return Err(validator::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")));
}
}