1use anyhow::{Context, Result};
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23use std::path::Path;
24use tracing::{debug, info, trace, warn};
25use validator::Validate;
26
27use sentinel_common::{
28 errors::{SentinelError, SentinelResult},
29 limits::Limits,
30 types::Priority,
31};
32
33pub mod agents;
38mod defaults;
39pub mod filters;
40pub mod flatten;
41mod kdl;
42#[cfg(feature = "runtime")]
43pub mod multi_file;
44pub mod namespace;
45pub mod observability;
46pub mod resolution;
47pub mod routes;
48pub mod server;
49pub mod upstreams;
50#[cfg(feature = "validation")]
51pub mod validate;
52pub mod validation;
53pub mod waf;
54
55pub use agents::{
61 AgentConfig, AgentEvent, AgentTlsConfig, AgentTransport, AgentType, BodyStreamingMode,
62};
63
64pub use defaults::{create_default_config, DEFAULT_CONFIG_KDL};
66
67pub use filters::*;
69
70#[cfg(feature = "runtime")]
72pub use multi_file::{ConfigDirectory, MultiFileLoader};
73
74pub use namespace::{ExportConfig, NamespaceConfig, ServiceConfig};
76
77pub use flatten::FlattenedConfig;
79
80pub use resolution::ResourceResolver;
82
83pub use observability::{
85 AccessLogConfig, AccessLogFields, AuditLogConfig, ErrorLogConfig, LoggingConfig,
86 MetricsConfig, ObservabilityConfig, TracingBackend, TracingConfig,
87};
88
89pub use routes::{
91 ApiSchemaConfig, BuiltinHandler, CacheBackend, CacheStorageConfig, ErrorFormat, ErrorPage,
92 ErrorPageConfig, FallbackConfig, FallbackTriggers, FallbackUpstream, FailureMode,
93 GuardrailAction, GuardrailFailureMode, GuardrailsConfig, HeaderModifications, InferenceConfig,
94 InferenceProvider, InferenceRouting, InferenceRoutingStrategy, MatchCondition,
95 ModelRoutingConfig, ModelUpstreamMapping, PiiAction, PiiDetectionConfig,
96 PromptInjectionConfig, RateLimitPolicy, RouteCacheConfig, RouteConfig, RoutePolicies,
97 ServiceType, StaticFileConfig, TokenEstimation, TokenRateLimit,
98};
99
100pub use server::{ListenerConfig, ListenerProtocol, ServerConfig, SniCertificate, TlsConfig};
102
103pub use sentinel_common::TraceIdFormat;
105
106pub use sentinel_common::budget::{
108 BudgetPeriod, CostAttributionConfig, ModelPricing, TokenBudgetConfig,
109};
110
111pub use upstreams::{
113 ConnectionPoolConfig, HealthCheck, HttpVersionConfig, UpstreamConfig, UpstreamPeer,
114 UpstreamTarget, UpstreamTimeouts, UpstreamTlsConfig,
115};
116
117pub use validation::ValidationContext;
119
120pub use waf::{
122 BodyInspectionPolicy, ExclusionScope, RuleExclusion, WafConfig, WafEngine, WafMode, WafRuleset,
123};
124
125pub use sentinel_common::types::LoadBalancingAlgorithm;
127
128pub const CURRENT_SCHEMA_VERSION: &str = "1.0";
134
135pub const MIN_SCHEMA_VERSION: &str = "1.0";
137
138#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
140#[validate(schema(function = "validation::validate_config_semantics"))]
141pub struct Config {
142 #[serde(default = "default_schema_version")]
145 pub schema_version: String,
146
147 pub server: ServerConfig,
149
150 #[validate(length(min = 1, message = "At least one listener is required"))]
152 pub listeners: Vec<ListenerConfig>,
153
154 pub routes: Vec<RouteConfig>,
156
157 #[serde(default)]
159 pub upstreams: HashMap<String, UpstreamConfig>,
160
161 #[serde(default)]
163 pub filters: HashMap<String, FilterConfig>,
164
165 #[serde(default)]
167 pub agents: Vec<AgentConfig>,
168
169 #[serde(default)]
171 pub waf: Option<WafConfig>,
172
173 #[serde(default, skip_serializing_if = "Vec::is_empty")]
180 pub namespaces: Vec<NamespaceConfig>,
181
182 #[serde(default)]
184 pub limits: Limits,
185
186 #[serde(default)]
188 pub observability: ObservabilityConfig,
189
190 #[serde(default)]
192 pub rate_limits: GlobalRateLimitConfig,
193
194 #[serde(default)]
196 pub cache: Option<CacheStorageConfig>,
197
198 #[serde(skip)]
200 pub default_upstream: Option<UpstreamPeer>,
201}
202
203fn default_schema_version() -> String {
205 CURRENT_SCHEMA_VERSION.to_string()
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
210pub enum SchemaCompatibility {
211 Exact,
213 Compatible,
215 Newer { config_version: String, max_supported: String },
217 Older { config_version: String, min_supported: String },
219 Invalid { config_version: String, reason: String },
221}
222
223impl SchemaCompatibility {
224 pub fn is_loadable(&self) -> bool {
226 matches!(self, Self::Exact | Self::Compatible | Self::Newer { .. })
227 }
228
229 pub fn warning(&self) -> Option<String> {
231 match self {
232 Self::Newer { config_version, max_supported } => Some(format!(
233 "Config schema version {} is newer than supported version {}. Some features may not work.",
234 config_version, max_supported
235 )),
236 _ => None,
237 }
238 }
239
240 pub fn error(&self) -> Option<String> {
242 match self {
243 Self::Older { config_version, min_supported } => Some(format!(
244 "Config schema version {} is older than minimum supported version {}. Please update your configuration.",
245 config_version, min_supported
246 )),
247 Self::Invalid { config_version, reason } => Some(format!(
248 "Invalid schema version '{}': {}",
249 config_version, reason
250 )),
251 _ => None,
252 }
253 }
254}
255
256fn parse_version(version: &str) -> Option<(u32, u32)> {
258 let parts: Vec<&str> = version.trim().split('.').collect();
259 if parts.len() != 2 {
260 return None;
261 }
262 let major = parts[0].parse().ok()?;
263 let minor = parts[1].parse().ok()?;
264 Some((major, minor))
265}
266
267pub fn check_schema_compatibility(config_version: &str) -> SchemaCompatibility {
269 let config_ver = match parse_version(config_version) {
270 Some(v) => v,
271 None => return SchemaCompatibility::Invalid {
272 config_version: config_version.to_string(),
273 reason: "Expected format: major.minor (e.g., '1.0')".to_string(),
274 },
275 };
276
277 let current_ver = parse_version(CURRENT_SCHEMA_VERSION).unwrap();
278 let min_ver = parse_version(MIN_SCHEMA_VERSION).unwrap();
279
280 if config_ver < min_ver {
282 return SchemaCompatibility::Older {
283 config_version: config_version.to_string(),
284 min_supported: MIN_SCHEMA_VERSION.to_string(),
285 };
286 }
287
288 if config_ver > current_ver {
290 return SchemaCompatibility::Newer {
291 config_version: config_version.to_string(),
292 max_supported: CURRENT_SCHEMA_VERSION.to_string(),
293 };
294 }
295
296 if config_ver == current_ver {
298 return SchemaCompatibility::Exact;
299 }
300
301 SchemaCompatibility::Compatible
303}
304
305impl Config {
310 pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
312 let path = path.as_ref();
313
314 trace!(
315 path = %path.display(),
316 "Loading configuration from file"
317 );
318
319 let content = std::fs::read_to_string(path)
320 .with_context(|| format!("Failed to read config file: {:?}", path))?;
321
322 let extension = path
323 .extension()
324 .and_then(|ext| ext.to_str())
325 .unwrap_or("kdl");
326
327 debug!(
328 path = %path.display(),
329 format = extension,
330 content_length = content.len(),
331 "Read configuration file"
332 );
333
334 let config = match extension {
335 "kdl" => Self::from_kdl(&content),
336 "json" => Self::from_json(&content),
337 "toml" => Self::from_toml(&content),
338 _ => Err(anyhow::anyhow!("Unsupported config format: {}", extension)),
339 }?;
340
341 info!(
342 path = %path.display(),
343 routes = config.routes.len(),
344 upstreams = config.upstreams.len(),
345 agents = config.agents.len(),
346 listeners = config.listeners.len(),
347 "Configuration loaded successfully"
348 );
349
350 Ok(config)
351 }
352
353 pub fn default_embedded() -> Result<Self> {
359 trace!("Loading embedded default configuration");
360
361 Self::from_kdl(DEFAULT_CONFIG_KDL).or_else(|e| {
362 warn!(
363 error = %e,
364 "Failed to parse embedded KDL config, using programmatic default"
365 );
366 Ok(create_default_config())
367 })
368 }
369
370 pub fn from_kdl(content: &str) -> Result<Self> {
372 trace!(content_length = content.len(), "Parsing KDL configuration");
373 let doc: ::kdl::KdlDocument = content.parse().map_err(|e: ::kdl::KdlError| {
374 use miette::Diagnostic;
375
376 let mut error_msg = String::new();
377 error_msg.push_str("KDL configuration parse error:\n\n");
378
379 let mut found_details = false;
380 if let Some(related) = e.related() {
381 for diagnostic in related {
382 let diag_str = format!("{}", diagnostic);
383 error_msg.push_str(&format!(" {}\n", diag_str));
384 found_details = true;
385
386 if let Some(labels) = diagnostic.labels() {
387 for label in labels {
388 let offset = label.offset();
389 let (line, col) = kdl::offset_to_line_col(content, offset);
390 error_msg
391 .push_str(&format!("\n --> at line {}, column {}\n", line, col));
392
393 let lines: Vec<&str> = content.lines().collect();
394
395 if line > 1 {
396 if let Some(lc) = lines.get(line.saturating_sub(2)) {
397 error_msg.push_str(&format!("{:>4} | {}\n", line - 1, lc));
398 }
399 }
400
401 if let Some(line_content) = lines.get(line.saturating_sub(1)) {
402 error_msg.push_str(&format!("{:>4} | {}\n", line, line_content));
403 error_msg.push_str(&format!(
404 " | {}^",
405 " ".repeat(col.saturating_sub(1))
406 ));
407 if let Some(label_msg) = label.label() {
408 error_msg.push_str(&format!(" {}", label_msg));
409 }
410 error_msg.push('\n');
411 }
412
413 if let Some(lc) = lines.get(line) {
414 error_msg.push_str(&format!("{:>4} | {}\n", line + 1, lc));
415 }
416 }
417 }
418
419 if let Some(help) = diagnostic.help() {
420 error_msg.push_str(&format!("\n Help: {}\n", help));
421 }
422 }
423 }
424
425 if !found_details {
426 error_msg.push_str(&format!(" {}\n", e));
427 error_msg.push_str("\n Note: Check your KDL syntax. Common issues:\n");
428 error_msg.push_str(" - Unclosed strings (missing closing quote)\n");
429 error_msg.push_str(" - Unclosed blocks (missing closing brace)\n");
430 error_msg.push_str(" - Invalid node names or values\n");
431 }
432
433 if let Some(help) = e.help() {
434 error_msg.push_str(&format!("\n Help: {}\n", help));
435 }
436
437 anyhow::anyhow!("{}", error_msg)
438 })?;
439
440 kdl::parse_kdl_document(doc)
441 }
442
443 pub fn from_json(content: &str) -> Result<Self> {
445 trace!(content_length = content.len(), "Parsing JSON configuration");
446 serde_json::from_str(content).context("Failed to parse JSON configuration")
447 }
448
449 pub fn from_toml(content: &str) -> Result<Self> {
451 trace!(content_length = content.len(), "Parsing TOML configuration");
452 toml::from_str(content).context("Failed to parse TOML configuration")
453 }
454
455 pub fn check_schema_version(&self) -> SchemaCompatibility {
457 check_schema_compatibility(&self.schema_version)
458 }
459
460 pub fn validate(&self) -> SentinelResult<()> {
462 trace!(
463 routes = self.routes.len(),
464 upstreams = self.upstreams.len(),
465 agents = self.agents.len(),
466 schema_version = %self.schema_version,
467 "Starting configuration validation"
468 );
469
470 let compat = self.check_schema_version();
472 if let Some(warning) = compat.warning() {
473 warn!("{}", warning);
474 }
475 if !compat.is_loadable() {
476 return Err(SentinelError::Config {
477 message: compat.error().unwrap_or_else(|| "Unknown schema version error".to_string()),
478 source: None,
479 });
480 }
481 trace!(
482 schema_version = %self.schema_version,
483 compatibility = ?compat,
484 "Schema version check passed"
485 );
486
487 Validate::validate(self).map_err(|e| SentinelError::Config {
488 message: format!("Configuration validation failed: {}", e),
489 source: None,
490 })?;
491
492 trace!("Schema validation passed");
493
494 self.validate_routes()?;
495 trace!("Route validation passed");
496
497 self.validate_upstreams()?;
498 trace!("Upstream validation passed");
499
500 self.validate_agents()?;
501 trace!("Agent validation passed");
502
503 self.limits.validate()?;
504 trace!("Limits validation passed");
505
506 debug!(
507 routes = self.routes.len(),
508 upstreams = self.upstreams.len(),
509 agents = self.agents.len(),
510 "Configuration validation successful"
511 );
512
513 Ok(())
514 }
515
516 fn validate_routes(&self) -> SentinelResult<()> {
517 for route in &self.routes {
518 if let Some(upstream) = &route.upstream {
519 if !self.upstreams.contains_key(upstream) {
520 return Err(SentinelError::Config {
521 message: format!(
522 "Route '{}' references non-existent upstream '{}'",
523 route.id, upstream
524 ),
525 source: None,
526 });
527 }
528 }
529
530 for filter_id in &route.filters {
531 if !self.filters.contains_key(filter_id) {
532 return Err(SentinelError::Config {
533 message: format!(
534 "Route '{}' references non-existent filter '{}'",
535 route.id, filter_id
536 ),
537 source: None,
538 });
539 }
540 }
541 }
542
543 for (filter_id, filter_config) in &self.filters {
544 if let Filter::Agent(agent_filter) = &filter_config.filter {
545 if !self.agents.iter().any(|a| a.id == agent_filter.agent) {
546 return Err(SentinelError::Config {
547 message: format!(
548 "Filter '{}' references non-existent agent '{}'",
549 filter_id, agent_filter.agent
550 ),
551 source: None,
552 });
553 }
554 }
555 }
556
557 Ok(())
558 }
559
560 fn validate_upstreams(&self) -> SentinelResult<()> {
561 for (id, upstream) in &self.upstreams {
562 if upstream.targets.is_empty() {
563 return Err(SentinelError::Config {
564 message: format!("Upstream '{}' has no targets", id),
565 source: None,
566 });
567 }
568 }
569 Ok(())
570 }
571
572 fn validate_agents(&self) -> SentinelResult<()> {
573 for agent in &self.agents {
574 if agent.timeout_ms == 0 {
575 return Err(SentinelError::Config {
576 message: format!("Agent '{}' has invalid timeout", agent.id),
577 source: None,
578 });
579 }
580
581 if let AgentTransport::UnixSocket { path } = &agent.transport {
582 if !path.exists() && !path.parent().is_some_and(|p| p.exists()) {
583 return Err(SentinelError::Config {
584 message: format!(
585 "Agent '{}' unix socket path parent directory doesn't exist: {:?}",
586 agent.id, path
587 ),
588 source: None,
589 });
590 }
591 }
592 }
593 Ok(())
594 }
595
596 pub fn default_for_testing() -> Self {
598 use sentinel_common::types::LoadBalancingAlgorithm;
599
600 let mut upstreams = HashMap::new();
601 upstreams.insert(
602 "default".to_string(),
603 UpstreamConfig {
604 id: "default".to_string(),
605 targets: vec![UpstreamTarget {
606 address: "127.0.0.1:8081".to_string(),
607 weight: 1,
608 max_requests: None,
609 metadata: HashMap::new(),
610 }],
611 load_balancing: LoadBalancingAlgorithm::RoundRobin,
612 health_check: None,
613 connection_pool: ConnectionPoolConfig::default(),
614 timeouts: UpstreamTimeouts::default(),
615 tls: None,
616 http_version: HttpVersionConfig::default(),
617 },
618 );
619
620 Self {
621 schema_version: CURRENT_SCHEMA_VERSION.to_string(),
622 server: ServerConfig {
623 worker_threads: 4,
624 max_connections: 1000,
625 graceful_shutdown_timeout_secs: 30,
626 daemon: false,
627 pid_file: None,
628 user: None,
629 group: None,
630 working_directory: None,
631 trace_id_format: Default::default(),
632 auto_reload: false,
633 },
634 listeners: vec![ListenerConfig {
635 id: "http".to_string(),
636 address: "0.0.0.0:8080".to_string(),
637 protocol: ListenerProtocol::Http,
638 tls: None,
639 default_route: Some("default".to_string()),
640 request_timeout_secs: 60,
641 keepalive_timeout_secs: 75,
642 max_concurrent_streams: 100,
643 }],
644 routes: vec![RouteConfig {
645 id: "default".to_string(),
646 priority: Priority::Normal,
647 matches: vec![MatchCondition::PathPrefix("/".to_string())],
648 upstream: Some("default".to_string()),
649 service_type: ServiceType::Web,
650 policies: RoutePolicies::default(),
651 filters: vec![],
652 builtin_handler: None,
653 waf_enabled: false,
654 circuit_breaker: None,
655 retry_policy: None,
656 static_files: None,
657 api_schema: None,
658 inference: None,
659 error_pages: None,
660 websocket: false,
661 websocket_inspection: false,
662 shadow: None,
663 fallback: None,
664 }],
665 upstreams,
666 filters: HashMap::new(),
667 agents: vec![],
668 waf: None,
669 namespaces: vec![],
670 limits: Limits::for_testing(),
671 observability: ObservabilityConfig::default(),
672 rate_limits: GlobalRateLimitConfig::default(),
673 cache: None,
674 default_upstream: Some(UpstreamPeer {
675 address: "127.0.0.1:8081".to_string(),
676 tls: false,
677 host: "localhost".to_string(),
678 connect_timeout_secs: 10,
679 read_timeout_secs: 30,
680 write_timeout_secs: 30,
681 }),
682 }
683 }
684
685 pub fn reload(&mut self, path: impl AsRef<Path>) -> SentinelResult<()> {
687 let path = path.as_ref();
688 debug!(
689 path = %path.display(),
690 "Reloading configuration"
691 );
692
693 let new_config = Self::from_file(path).map_err(|e| SentinelError::Config {
694 message: format!("Failed to reload configuration: {}", e),
695 source: None,
696 })?;
697
698 new_config.validate()?;
699
700 info!(
701 path = %path.display(),
702 routes = new_config.routes.len(),
703 upstreams = new_config.upstreams.len(),
704 "Configuration reloaded successfully"
705 );
706
707 *self = new_config;
708 Ok(())
709 }
710
711 pub fn get_route(&self, id: &str) -> Option<&RouteConfig> {
713 self.routes.iter().find(|r| r.id == id)
714 }
715
716 pub fn get_upstream(&self, id: &str) -> Option<&UpstreamConfig> {
718 self.upstreams.get(id)
719 }
720
721 pub fn get_agent(&self, id: &str) -> Option<&AgentConfig> {
723 self.agents.iter().find(|a| a.id == id)
724 }
725}
726
727#[cfg(test)]
732mod tests {
733 use super::*;
734
735 #[test]
736 fn test_parse_version() {
737 assert_eq!(parse_version("1.0"), Some((1, 0)));
738 assert_eq!(parse_version("2.5"), Some((2, 5)));
739 assert_eq!(parse_version("10.20"), Some((10, 20)));
740 assert_eq!(parse_version("1"), None);
741 assert_eq!(parse_version("1.0.0"), None);
742 assert_eq!(parse_version("abc"), None);
743 assert_eq!(parse_version(""), None);
744 }
745
746 #[test]
747 fn test_schema_compatibility_exact() {
748 let compat = check_schema_compatibility(CURRENT_SCHEMA_VERSION);
749 assert_eq!(compat, SchemaCompatibility::Exact);
750 assert!(compat.is_loadable());
751 assert!(compat.warning().is_none());
752 assert!(compat.error().is_none());
753 }
754
755 #[test]
756 fn test_schema_compatibility_newer() {
757 let compat = check_schema_compatibility("99.0");
758 assert!(matches!(compat, SchemaCompatibility::Newer { .. }));
759 assert!(compat.is_loadable()); assert!(compat.warning().is_some());
761 assert!(compat.error().is_none());
762 }
763
764 #[test]
765 fn test_schema_compatibility_older() {
766 let compat = check_schema_compatibility("0.5");
768 assert!(matches!(compat, SchemaCompatibility::Older { .. }));
769 assert!(!compat.is_loadable());
770 assert!(compat.warning().is_none());
771 assert!(compat.error().is_some());
772 }
773
774 #[test]
775 fn test_schema_compatibility_invalid() {
776 let compat = check_schema_compatibility("not-a-version");
777 assert!(matches!(compat, SchemaCompatibility::Invalid { .. }));
778 assert!(!compat.is_loadable());
779 assert!(compat.error().is_some());
780
781 let compat = check_schema_compatibility("1.0.0");
782 assert!(matches!(compat, SchemaCompatibility::Invalid { .. }));
783 }
784
785 #[test]
786 fn test_default_schema_version() {
787 let config = Config::default_for_testing();
788 assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION);
789 }
790
791 #[test]
792 fn test_kdl_with_schema_version() {
793 let kdl = r#"
794 schema-version "1.0"
795 server {
796 worker-threads 4
797 }
798 listeners {
799 listener "http" {
800 address "0.0.0.0:8080"
801 protocol "http"
802 }
803 }
804 "#;
805 let config = Config::from_kdl(kdl).unwrap();
806 assert_eq!(config.schema_version, "1.0");
807 }
808
809 #[test]
810 fn test_kdl_without_schema_version_uses_default() {
811 let kdl = r#"
812 server {
813 worker-threads 4
814 }
815 listeners {
816 listener "http" {
817 address "0.0.0.0:8080"
818 protocol "http"
819 }
820 }
821 "#;
822 let config = Config::from_kdl(kdl).unwrap();
823 assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION);
824 }
825}