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