use tracing::warn;
use crate::{
config::{Cluster, InsecureOptions, UpstreamAuthority},
errors::ProxyError,
};
pub(super) fn validate_tls_settings(cluster: &Cluster, insecure_options: &InsecureOptions) -> Result<(), ProxyError> {
let Some(tls) = &cluster.tls else {
return Ok(());
};
if let Some(sni) = &tls.sni {
praxis_tls::validate_sni_name(sni)
.map_err(|err| ProxyError::Config(format!("cluster '{}': sni {err}", cluster.name)))?;
}
let sni = if tls.sni.is_some() {
SniSource::Configured
} else {
endpoint_sni(cluster)
};
check_sni_verify_requirement(sni, tls.verify, &cluster.name, insecure_options)?;
check_no_verify_requirement(tls.verify, &cluster.name, insecure_options)?;
check_no_upstream_crls(tls.ca.as_ref(), &cluster.name)?;
Ok(())
}
#[derive(Clone, Copy, Debug)]
enum SniSource {
Configured,
Endpoint,
Missing,
}
fn endpoint_sni(cluster: &Cluster) -> SniSource {
if cluster
.http
.authority
.as_ref()
.is_some_and(UpstreamAuthority::follows_endpoint)
{
SniSource::Endpoint
} else {
SniSource::Missing
}
}
fn check_sni_verify_requirement(
sni: SniSource,
verify: bool,
cluster_name: &str,
insecure_options: &InsecureOptions,
) -> Result<(), ProxyError> {
match sni {
SniSource::Configured | SniSource::Endpoint => return Ok(()),
SniSource::Missing => {},
}
if !verify {
return Ok(());
}
if insecure_options.allow_tls_without_sni {
warn!(
cluster = %cluster_name,
"upstream TLS without tls.sni: the certificate is verified against the cluster's fixed authority \
if set, else the client's Host header, falling back to the endpoint address when that is not a \
hostname (allowed by insecure_options.allow_tls_without_sni)"
);
return Ok(());
}
Err(ProxyError::Config(format!(
"cluster '{cluster_name}': upstream TLS with verification enabled but no sni configured; \
set tls.sni, or authority: {{ from: endpoint }} to verify each endpoint against its own name or IP SAN, \
or set insecure_options.allow_tls_without_sni: true to verify against the authority or client Host header \
instead"
)))
}
fn check_no_verify_requirement(
verify: bool,
cluster_name: &str,
insecure_options: &InsecureOptions,
) -> Result<(), ProxyError> {
if verify {
return Ok(());
}
if insecure_options.allow_tls_no_verify {
warn!(
cluster = %cluster_name,
"upstream TLS certificate verification is disabled \
(allowed by insecure_options.allow_tls_no_verify)"
);
return Ok(());
}
Err(ProxyError::Config(format!(
"cluster '{cluster_name}': upstream TLS certificate verification is disabled (verify: false); \
set insecure_options.allow_tls_no_verify: true to allow this"
)))
}
fn check_no_upstream_crls(ca: Option<&praxis_tls::CaConfig>, cluster_name: &str) -> Result<(), ProxyError> {
if ca.is_some_and(|ca| !ca.crl_paths.is_empty()) {
return Err(ProxyError::Config(format!(
"cluster '{cluster_name}': tls.ca.crl_paths is not supported for upstream connections \
(CRL checking is only available for listener client authentication); remove it"
)));
}
Ok(())
}
#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::needless_raw_strings,
clippy::needless_raw_string_hashes,
reason = "tests use unwrap/expect/indexing/raw strings for brevity"
)]
mod tests {
use std::sync::{Arc, Mutex};
use praxis_tls::{CaConfig, ClusterTls};
use super::super::validate_clusters;
use crate::config::{AuthoritySource, Cluster, ClusterHttpOptions, InsecureOptions, UpstreamAuthority};
#[test]
fn reject_empty_sni() {
let clusters = vec![Cluster {
tls: Some(ClusterTls {
sni: Some(String::new()),
..ClusterTls::default()
}),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(err.to_string().contains("empty"), "got: {err}");
}
#[test]
fn reject_overlong_sni() {
let long_sni = format!("{}.example.com", "a".repeat(250));
let clusters = vec![Cluster {
tls: Some(ClusterTls {
sni: Some(long_sni),
..ClusterTls::default()
}),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(err.to_string().contains("253"), "got: {err}");
}
#[test]
fn reject_sni_with_invalid_chars() {
let clusters = vec![Cluster {
tls: Some(ClusterTls {
sni: Some("api.exam ple.com".into()),
..ClusterTls::default()
}),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(err.to_string().contains("invalid characters"), "got: {err}");
}
#[test]
fn accept_valid_sni() {
let clusters = vec![Cluster {
tls: Some(ClusterTls {
sni: Some("api.example.com".into()),
..ClusterTls::default()
}),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
validate_clusters(&clusters, &InsecureOptions::default()).unwrap();
}
#[test]
fn reject_sni_ipv4_address() {
let clusters = vec![Cluster {
tls: Some(ClusterTls {
sni: Some("192.168.1.1".into()),
..ClusterTls::default()
}),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(err.to_string().contains("IP address"), "got: {err}");
}
#[test]
fn reject_sni_ipv4_loopback() {
let clusters = vec![Cluster {
tls: Some(ClusterTls {
sni: Some("127.0.0.1".into()),
..ClusterTls::default()
}),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(err.to_string().contains("IP address"), "got: {err}");
}
#[test]
fn reject_sni_ipv6_loopback() {
let clusters = vec![Cluster {
tls: Some(ClusterTls {
sni: Some("::1".into()),
..ClusterTls::default()
}),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(err.to_string().contains("IP address"), "got: {err}");
}
#[test]
fn reject_sni_ipv6_full() {
let clusters = vec![Cluster {
tls: Some(ClusterTls {
sni: Some("2001:db8::1".into()),
..ClusterTls::default()
}),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(err.to_string().contains("IP address"), "got: {err}");
}
#[test]
fn reject_partial_wildcard_sni() {
let clusters = vec![Cluster {
tls: Some(ClusterTls {
sni: Some("a*b.example.com".into()),
..ClusterTls::default()
}),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(err.to_string().contains("wildcard"), "got: {err}");
}
#[test]
fn reject_nested_wildcard_sni() {
let clusters = vec![Cluster {
tls: Some(ClusterTls {
sni: Some("*.*.example.com".into()),
..ClusterTls::default()
}),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(err.to_string().contains("wildcard"), "got: {err}");
}
#[test]
fn reject_non_leftmost_wildcard_sni() {
let clusters = vec![Cluster {
tls: Some(ClusterTls {
sni: Some("foo.*.example.com".into()),
..ClusterTls::default()
}),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(err.to_string().contains("wildcard"), "got: {err}");
}
#[test]
fn accept_wildcard_sni() {
let clusters = vec![Cluster {
tls: Some(ClusterTls {
sni: Some("*.example.com".into()),
..ClusterTls::default()
}),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
validate_clusters(&clusters, &InsecureOptions::default()).unwrap();
}
#[test]
fn reject_sni_with_overlong_label() {
let long_label = "a".repeat(64);
let sni = format!("{long_label}.example.com");
let clusters = vec![Cluster {
tls: Some(ClusterTls {
sni: Some(sni),
..ClusterTls::default()
}),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(
err.to_string().contains("label exceeds 63 characters"),
"label >63 chars should be rejected: {err}"
);
}
#[test]
fn accept_sni_with_exact_63_char_label() {
let label = "a".repeat(63);
let sni = format!("{label}.example.com");
let clusters = vec![Cluster {
tls: Some(ClusterTls {
sni: Some(sni),
..ClusterTls::default()
}),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
validate_clusters(&clusters, &InsecureOptions::default()).expect("63-char label should be valid");
}
#[test]
fn reject_sni_with_empty_label() {
let clusters = vec![Cluster {
tls: Some(ClusterTls {
sni: Some("api..example.com".into()),
..ClusterTls::default()
}),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(
err.to_string().contains("label is empty"),
"empty label (consecutive dots) should be rejected: {err}"
);
}
#[test]
fn reject_sni_with_underscore() {
let clusters = vec![Cluster {
tls: Some(ClusterTls {
sni: Some("api_server.example.com".into()),
..ClusterTls::default()
}),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(
err.to_string().contains("invalid characters"),
"underscore in SNI label should be rejected: {err}"
);
}
#[test]
fn accept_sni_with_hyphen() {
let clusters = vec![Cluster {
tls: Some(ClusterTls {
sni: Some("api-server.example.com".into()),
..ClusterTls::default()
}),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
validate_clusters(&clusters, &InsecureOptions::default()).expect("hyphen in SNI label should be valid");
}
#[test]
fn reject_sni_at_254_chars() {
let sni = format!(
"{}.{}.{}.{}.com",
"a".repeat(63),
"b".repeat(63),
"c".repeat(63),
"d".repeat(63),
);
assert!(sni.len() > 253, "test SNI should exceed 253 chars");
let clusters = vec![Cluster {
tls: Some(ClusterTls {
sni: Some(sni),
..ClusterTls::default()
}),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(
err.to_string().contains("253"),
"SNI >253 chars should be rejected: {err}"
);
}
#[test]
fn reject_tls_without_sni() {
let clusters = vec![Cluster {
tls: Some(ClusterTls::default()),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
let message = err.to_string();
assert!(
message.contains("no sni configured"),
"should reject TLS+verify without SNI: {message}"
);
assert!(
message.contains("authority: { from: endpoint }"),
"the error should offer the endpoint authority as a fix: {message}"
);
assert!(
message.contains("verify against the authority or client Host header instead"),
"the error should say what the opt-in really does: {message}"
);
}
#[test]
fn allow_tls_without_sni_override() {
let clusters = vec![Cluster {
tls: Some(ClusterTls::default()),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
let opts = InsecureOptions {
allow_tls_without_sni: true,
..InsecureOptions::default()
};
let logs = capture_warnings(|| {
validate_clusters(&clusters, &opts).expect("allow_tls_without_sni should demote error to warning");
});
assert!(
logs.contains("client's Host header") && logs.contains("falling back to the endpoint address"),
"the warning should say where the verification name comes from: {logs}"
);
}
#[test]
fn accept_verify_without_sni_when_authority_follows_hostname_endpoints() {
let clusters = vec![endpoint_authority_cluster(&[
"api-a.example.com:443",
"api-b.example.com:443",
])];
validate_clusters(&clusters, &InsecureOptions::default())
.expect("each attempt can name its own hostname endpoint in SNI, so verify needs no tls.sni");
}
#[test]
fn accept_verify_without_sni_when_authority_follows_an_ip_endpoint() {
let clusters = vec![endpoint_authority_cluster(&["api-a.example.com:443", "10.0.0.2:443"])];
validate_clusters(&clusters, &InsecureOptions::default())
.expect("an IP endpoint is verified against its certificate's IP SAN, so verify needs no tls.sni");
}
#[test]
fn accept_verify_without_sni_when_authority_follows_an_ipv6_endpoint() {
let clusters = vec![endpoint_authority_cluster(&["[2001:db8::1]:443"])];
validate_clusters(&clusters, &InsecureOptions::default())
.expect("a bracketed IPv6 endpoint is verified against its IP SAN too");
}
#[test]
fn reject_verify_without_sni_for_an_ip_endpoint_without_endpoint_authority() {
let clusters = vec![Cluster {
tls: Some(ClusterTls::default()),
..Cluster::with_defaults("web", vec!["10.0.0.2:443".into()])
}];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(
err.to_string().contains("no sni configured;"),
"without the endpoint authority the name would come from the client Host, so tls.sni is still required: {err}"
);
}
#[test]
fn reject_verify_without_sni_for_a_fixed_authority_with_hostname_endpoints() {
let clusters = vec![Cluster {
http: ClusterHttpOptions {
authority: Some("api.example.com".into()),
..ClusterHttpOptions::default()
},
tls: Some(ClusterTls::default()),
..Cluster::with_defaults("web", vec!["api-a.example.com:443".into()])
}];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(
err.to_string().contains("no sni configured;"),
"validation does not count a fixed authority as a configured SNI, so tls.sni is still required: {err}"
);
}
#[test]
fn reject_tls_no_verify_without_insecure_option() {
let clusters = vec![Cluster {
tls: Some(ClusterTls {
verify: false,
..ClusterTls::default()
}),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(
err.to_string().contains("verify: false"),
"should reject verify: false without insecure option: {err}"
);
assert!(
err.to_string().contains("allow_tls_no_verify"),
"error should mention allow_tls_no_verify: {err}"
);
}
#[test]
fn allow_tls_no_verify_with_insecure_option() {
let clusters = vec![Cluster {
tls: Some(ClusterTls {
verify: false,
..ClusterTls::default()
}),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
let opts = InsecureOptions {
allow_tls_no_verify: true,
..InsecureOptions::default()
};
validate_clusters(&clusters, &opts).expect("allow_tls_no_verify should demote error to warning");
}
#[test]
fn tls_no_verify_not_blocked_by_sni_check() {
let clusters = vec![Cluster {
tls: Some(ClusterTls {
verify: false,
..ClusterTls::default()
}),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
let opts = InsecureOptions {
allow_tls_no_verify: true,
..InsecureOptions::default()
};
validate_clusters(&clusters, &opts).expect("TLS without verify should not require SNI");
}
#[test]
fn reject_cluster_crl_paths() {
let clusters = vec![Cluster {
tls: Some(ClusterTls {
sni: Some("api.example.com".into()),
ca: Some(CaConfig {
ca_path: "/etc/ssl/ca.pem".into(),
crl_paths: vec!["/etc/ssl/crl.pem".into()],
}),
..ClusterTls::default()
}),
..Cluster::with_defaults("web", vec!["10.0.0.1:443".into()])
}];
let err = validate_clusters(&clusters, &InsecureOptions::default()).unwrap_err();
assert!(err.to_string().contains("crl_paths"), "got: {err}");
}
fn capture_warnings<F: FnOnce()>(func: F) -> String {
let buffer = Buffer(Arc::new(Mutex::new(Vec::new())));
let writer = buffer.clone();
let subscriber = tracing_subscriber::fmt()
.with_writer(move || writer.clone())
.with_ansi(false)
.with_max_level(tracing::Level::WARN)
.finish();
tracing::subscriber::with_default(subscriber, || {
tracing::callsite::rebuild_interest_cache();
func();
});
let bytes = buffer.0.lock().unwrap().clone();
String::from_utf8_lossy(&bytes).into_owned()
}
#[derive(Clone)]
struct Buffer(Arc<Mutex<Vec<u8>>>);
impl std::io::Write for Buffer {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn endpoint_authority_cluster(endpoints: &[&str]) -> Cluster {
Cluster {
http: ClusterHttpOptions {
authority: Some(UpstreamAuthority::Derived {
from: AuthoritySource::Endpoint,
}),
..ClusterHttpOptions::default()
},
tls: Some(ClusterTls::default()),
..Cluster::with_defaults("web", endpoints.iter().map(|&address| address.into()).collect())
}
}
}