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