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