use super::{ValidationResult, ValidationWarning};
use crate::filters::{Filter, HeadersFilter};
use crate::Config;
pub fn lint_config(config: &Config) -> ValidationResult {
let mut result = ValidationResult::new();
for route in &config.routes {
if route.retry_policy.is_none() {
result.add_warning(ValidationWarning::new(format!(
"Route '{}' has no retry policy (recommended for production)",
route.id
)));
}
if route.policies.timeout_secs.is_none() {
result.add_warning(ValidationWarning::new(format!(
"Route '{}' has no timeout (recommended for production)",
route.id
)));
}
use crate::routes::ServiceType;
if route.upstream.is_none()
&& !matches!(
route.service_type,
ServiceType::Static | ServiceType::Builtin
)
{
result.add_warning(ValidationWarning::new(format!(
"Route '{}' has no upstream configured",
route.id
)));
}
}
for (name, upstream) in &config.upstreams {
if upstream.health_check.is_none() {
result.add_warning(ValidationWarning::new(format!(
"Upstream '{}' has no health check (recommended for production)",
name
)));
}
if upstream.targets.len() == 1 && upstream.health_check.is_none() {
result.add_warning(ValidationWarning::new(format!(
"Upstream '{}' has only one target and no health check (no failover possible)",
name
)));
}
}
let has_tls_listener = config.listeners.iter().any(|l| l.tls.is_some());
for listener in &config.listeners {
if listener.address.ends_with(":80") && listener.tls.is_none() {
result.add_warning(ValidationWarning::new(format!(
"Listener '{}' serves HTTP on port 80 without TLS (consider HTTPS redirect)",
listener.address
)));
}
}
if has_tls_listener {
check_hsts_headers(config, &mut result);
}
if !config.observability.metrics.enabled {
result.add_warning(ValidationWarning::new(
"Metrics are disabled (recommended for production monitoring)".to_string(),
));
}
if let Some(ref access_log) = config.observability.logging.access_log {
if !access_log.enabled {
result.add_warning(ValidationWarning::new(
"Access logs are disabled (recommended for debugging and compliance)".to_string(),
));
}
}
result
}
const HSTS_HEADER: &str = "Strict-Transport-Security";
fn check_hsts_headers(config: &Config, result: &mut ValidationResult) {
let has_hsts_in_route_policies = config
.routes
.iter()
.any(|route| route_has_hsts_header(&route.policies.response_headers));
let has_hsts_in_filter = config.filters.values().any(|filter_config| {
if let Filter::Headers(headers_filter) = &filter_config.filter {
headers_filter_has_hsts(headers_filter)
} else {
false
}
});
if !has_hsts_in_route_policies && !has_hsts_in_filter {
result.add_warning(ValidationWarning::new(
"TLS is enabled but no HSTS (Strict-Transport-Security) header is configured. \
Consider adding HSTS to protect against protocol downgrade attacks and cookie hijacking. \
Recommended value: 'max-age=31536000; includeSubDomains'".to_string(),
));
}
}
fn route_has_hsts_header(headers: &crate::HeaderModifications) -> bool {
let has_in_set = headers
.set
.keys()
.any(|k| k.eq_ignore_ascii_case(HSTS_HEADER));
let has_in_add = headers
.add
.keys()
.any(|k| k.eq_ignore_ascii_case(HSTS_HEADER));
has_in_set || has_in_add
}
fn headers_filter_has_hsts(filter: &HeadersFilter) -> bool {
let has_in_set = filter
.set
.keys()
.any(|k| k.eq_ignore_ascii_case(HSTS_HEADER));
let has_in_add = filter
.add
.keys()
.any(|k| k.eq_ignore_ascii_case(HSTS_HEADER));
has_in_set || has_in_add
}
#[cfg(test)]
mod tests {
use super::*;
use crate::filters::FilterConfig;
use crate::{
ConnectionPoolConfig, HttpVersionConfig, ListenerConfig, MatchCondition, RouteConfig,
RoutePolicies, ServiceType, TlsConfig, UpstreamConfig, UpstreamTarget, UpstreamTimeouts,
};
use std::collections::HashMap;
use std::path::PathBuf;
use zentinel_common::types::{LoadBalancingAlgorithm, Priority, TlsVersion};
fn test_route_config() -> RouteConfig {
RouteConfig {
id: "test".to_string(),
priority: Priority::NORMAL,
matches: vec![MatchCondition::PathPrefix("/".to_string())],
upstream: None,
service_type: ServiceType::Web,
policies: RoutePolicies::default(),
filters: vec![],
builtin_handler: None,
waf_enabled: false,
circuit_breaker: None,
retry_policy: None,
static_files: None,
api_schema: None,
error_pages: None,
websocket: false,
websocket_inspection: false,
inference: None,
shadow: None,
fallback: None,
}
}
fn test_upstream_config() -> UpstreamConfig {
UpstreamConfig {
id: "test".to_string(),
targets: vec![UpstreamTarget {
address: "127.0.0.1:8080".to_string(),
weight: 1,
max_requests: None,
metadata: HashMap::new(),
}],
load_balancing: LoadBalancingAlgorithm::RoundRobin,
sticky_session: None,
health_check: None,
connection_pool: ConnectionPoolConfig::default(),
timeouts: UpstreamTimeouts::default(),
tls: None,
http_version: HttpVersionConfig::default(),
}
}
fn test_listener_config(address: &str) -> ListenerConfig {
ListenerConfig {
id: "test".to_string(),
address: address.to_string(),
protocol: crate::ListenerProtocol::Http,
tls: None,
default_route: None,
request_timeout_secs: 60,
keepalive_timeout_secs: 75,
max_concurrent_streams: 100,
keepalive_max_requests: None,
}
}
fn test_tls_listener_config(address: &str) -> ListenerConfig {
ListenerConfig {
id: "tls-test".to_string(),
address: address.to_string(),
protocol: crate::ListenerProtocol::Http,
tls: Some(TlsConfig {
cert_file: Some(PathBuf::from("/path/to/cert.pem")),
key_file: Some(PathBuf::from("/path/to/key.pem")),
additional_certs: vec![],
ca_file: None,
min_version: TlsVersion::Tls12,
max_version: None,
cipher_suites: vec![],
client_auth: false,
ocsp_stapling: true,
session_resumption: true,
acme: None,
}),
default_route: None,
request_timeout_secs: 60,
keepalive_timeout_secs: 75,
max_concurrent_streams: 100,
keepalive_max_requests: None,
}
}
#[test]
fn test_lint_missing_retry_policy() {
let mut config = Config::default_for_testing();
config.routes = vec![test_route_config()];
let result = lint_config(&config);
assert!(result
.warnings
.iter()
.any(|w| w.message.contains("no retry policy")));
}
#[test]
fn test_lint_missing_health_check() {
let mut config = Config::default_for_testing();
config
.upstreams
.insert("test".to_string(), test_upstream_config());
let result = lint_config(&config);
assert!(result
.warnings
.iter()
.any(|w| w.message.contains("no health check")));
}
#[test]
fn test_lint_http_on_port_80() {
let mut config = Config::default_for_testing();
config.listeners = vec![test_listener_config("0.0.0.0:80")];
let result = lint_config(&config);
assert!(result
.warnings
.iter()
.any(|w| w.message.contains("without TLS")));
}
#[test]
fn test_lint_tls_without_hsts() {
let mut config = Config::default_for_testing();
config.listeners = vec![test_tls_listener_config("0.0.0.0:443")];
let result = lint_config(&config);
assert!(
result
.warnings
.iter()
.any(|w| w.message.contains("HSTS")
&& w.message.contains("Strict-Transport-Security"))
);
}
#[test]
fn test_lint_tls_with_hsts_in_route_policies() {
let mut config = Config::default_for_testing();
config.listeners = vec![test_tls_listener_config("0.0.0.0:443")];
let mut route = test_route_config();
route.policies.response_headers.set.insert(
"Strict-Transport-Security".to_string(),
"max-age=31536000; includeSubDomains".to_string(),
);
config.routes = vec![route];
let result = lint_config(&config);
assert!(
!result.warnings.iter().any(|w| w.message.contains("HSTS")),
"Should not warn about HSTS when it's configured in route policies"
);
}
#[test]
fn test_lint_tls_with_hsts_in_filter() {
let mut config = Config::default_for_testing();
config.listeners = vec![test_tls_listener_config("0.0.0.0:443")];
let mut headers_filter = HeadersFilter::default();
headers_filter.set.insert(
"Strict-Transport-Security".to_string(),
"max-age=31536000".to_string(),
);
config.filters.insert(
"hsts-filter".to_string(),
FilterConfig::new("hsts-filter", Filter::Headers(headers_filter)),
);
let result = lint_config(&config);
assert!(
!result.warnings.iter().any(|w| w.message.contains("HSTS")),
"Should not warn about HSTS when it's configured in headers filter"
);
}
#[test]
fn test_lint_hsts_case_insensitive() {
let mut config = Config::default_for_testing();
config.listeners = vec![test_tls_listener_config("0.0.0.0:443")];
let mut route = test_route_config();
route.policies.response_headers.set.insert(
"strict-transport-security".to_string(),
"max-age=31536000".to_string(),
);
config.routes = vec![route];
let result = lint_config(&config);
assert!(
!result.warnings.iter().any(|w| w.message.contains("HSTS")),
"Should detect HSTS header with case-insensitive matching"
);
}
#[test]
fn test_lint_no_hsts_warning_without_tls() {
let mut config = Config::default_for_testing();
config.listeners = vec![test_listener_config("0.0.0.0:8080")];
let result = lint_config(&config);
assert!(
!result.warnings.iter().any(|w| w.message.contains("HSTS")),
"Should not warn about HSTS when there's no TLS listener"
);
}
}