diff --git a/seaweed-volume/src/config.rs b/seaweed-volume/src/config.rs index 31aacac11..fac3b8135 100644 --- a/seaweed-volume/src/config.rs +++ b/seaweed-volume/src/config.rs @@ -2,6 +2,8 @@ use clap::Parser; use std::net::UdpSocket; use std::path::{Path, PathBuf}; +use crate::security::tls::TlsPolicy; + /// SeaweedFS Volume Server (Rust implementation) /// /// Start a volume server to provide storage spaces. @@ -245,6 +247,7 @@ pub struct VolumeServerConfig { pub grpc_cert_file: String, pub grpc_key_file: String, pub grpc_ca_file: String, + pub tls_policy: TlsPolicy, /// Enable batched write queue for improved throughput under load. pub enable_write_queue: bool, /// Path to security.toml — stored for SIGHUP reload. @@ -784,6 +787,7 @@ fn resolve_config(cli: Cli) -> VolumeServerConfig { grpc_cert_file: sec.grpc_cert_file, grpc_key_file: sec.grpc_key_file, grpc_ca_file: sec.grpc_ca_file, + tls_policy: sec.tls_policy, enable_write_queue: std::env::var("SEAWEED_WRITE_QUEUE") .map(|v| v == "1" || v == "true") .unwrap_or(false), @@ -812,6 +816,7 @@ pub struct SecurityConfig { pub grpc_cert_file: String, pub grpc_key_file: String, pub grpc_ca_file: String, + pub tls_policy: TlsPolicy, pub access_ui: bool, /// IPs from [guard] white_list in security.toml pub guard_white_list: Vec, @@ -833,6 +838,12 @@ const SECURITY_CONFIG_FILE_NAME: &str = "security.toml"; /// [https.volume] /// cert = "/path/to/cert.pem" /// key = "/path/to/key.pem" +/// ca = "/path/to/ca.pem" +/// +/// [tls] +/// min_version = "TLS 1.2" +/// max_version = "TLS 1.3" +/// cipher_suites = "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" /// /// [https.client] /// enabled = true @@ -874,6 +885,7 @@ pub fn parse_security_config(path: &str) -> SecurityConfig { Grpc, HttpsVolume, GrpcVolume, + Tls, Guard, Access, } @@ -909,6 +921,10 @@ pub fn parse_security_config(path: &str) -> SecurityConfig { section = Section::GrpcVolume; continue; } + if trimmed == "[tls]" { + section = Section::Tls; + continue; + } if trimmed == "[guard]" { section = Section::Guard; continue; @@ -961,6 +977,12 @@ pub fn parse_security_config(path: &str) -> SecurityConfig { "ca" => cfg.grpc_ca_file = value.to_string(), _ => {} }, + Section::Tls => match key { + "min_version" => cfg.tls_policy.min_version = value.to_string(), + "max_version" => cfg.tls_policy.max_version = value.to_string(), + "cipher_suites" => cfg.tls_policy.cipher_suites = value.to_string(), + _ => {} + }, Section::Guard => match key { "white_list" => { cfg.guard_white_list = value @@ -1075,6 +1097,15 @@ fn apply_env_overrides(cfg: &mut SecurityConfig) { } else if let Ok(v) = std::env::var("WEED_GRPC_VOLUME_CA") { cfg.grpc_ca_file = v; } + if let Ok(v) = std::env::var("WEED_TLS_MIN_VERSION") { + cfg.tls_policy.min_version = v; + } + if let Ok(v) = std::env::var("WEED_TLS_MAX_VERSION") { + cfg.tls_policy.max_version = v; + } + if let Ok(v) = std::env::var("WEED_TLS_CIPHER_SUITES") { + cfg.tls_policy.cipher_suites = v; + } if let Ok(v) = std::env::var("WEED_GUARD_WHITE_LIST") { cfg.guard_white_list = v .split(',') @@ -1154,6 +1185,9 @@ mod tests { "WEED_GRPC_VOLUME_KEY", "WEED_GRPC_CA", "WEED_GRPC_VOLUME_CA", + "WEED_TLS_MIN_VERSION", + "WEED_TLS_MAX_VERSION", + "WEED_TLS_CIPHER_SUITES", "WEED_GUARD_WHITE_LIST", "WEED_ACCESS_UI", ]; @@ -1424,6 +1458,32 @@ ca = "/etc/seaweedfs/client-ca.pem" }); } + #[test] + fn test_parse_security_config_uses_tls_policy_settings() { + let _guard = process_state_lock(); + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write( + tmp.path(), + r#" +[tls] +min_version = "TLS 1.2" +max_version = "TLS 1.3" +cipher_suites = "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" +"#, + ) + .unwrap(); + + with_cleared_security_env(|| { + let cfg = parse_security_config(tmp.path().to_str().unwrap()); + assert_eq!(cfg.tls_policy.min_version, "TLS 1.2"); + assert_eq!(cfg.tls_policy.max_version, "TLS 1.3"); + assert_eq!( + cfg.tls_policy.cipher_suites, + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" + ); + }); + } + #[test] fn test_merge_options_file_basic() { let tmp = tempfile::NamedTempFile::new().unwrap(); diff --git a/seaweed-volume/src/main.rs b/seaweed-volume/src/main.rs index 405f9872d..1d8257813 100644 --- a/seaweed-volume/src/main.rs +++ b/seaweed-volume/src/main.rs @@ -5,6 +5,7 @@ use tracing::{error, info, warn}; use seaweed_volume::config::{self, VolumeServerConfig}; use seaweed_volume::metrics; use seaweed_volume::pb::volume_server_pb::volume_server_server::VolumeServerServer; +use seaweed_volume::security::tls::build_rustls_server_config; use seaweed_volume::security::{Guard, SigningKey}; use seaweed_volume::server::debug::build_debug_router; use seaweed_volume::server::grpc_client::load_outgoing_grpc_tls; @@ -48,50 +49,6 @@ fn main() { } } -/// Build a rustls ServerConfig from cert, key, and optional CA PEM files. -/// When `ca_path` is non-empty, enables mTLS (client certificate verification). -fn load_rustls_config(cert_path: &str, key_path: &str, ca_path: &str) -> rustls::ServerConfig { - let cert_pem = std::fs::read(cert_path) - .unwrap_or_else(|e| panic!("Failed to read TLS cert file '{}': {}", cert_path, e)); - let key_pem = std::fs::read(key_path) - .unwrap_or_else(|e| panic!("Failed to read TLS key file '{}': {}", key_path, e)); - - let certs = rustls_pemfile::certs(&mut &cert_pem[..]) - .collect::, _>>() - .expect("Failed to parse TLS certificate PEM"); - let key = rustls_pemfile::private_key(&mut &key_pem[..]) - .expect("Failed to parse TLS private key PEM") - .expect("No private key found in PEM file"); - - let builder = rustls::ServerConfig::builder(); - if !ca_path.is_empty() { - // mTLS: verify client certificates against the CA - let ca_pem = std::fs::read(ca_path) - .unwrap_or_else(|e| panic!("Failed to read CA cert file '{}': {}", ca_path, e)); - let ca_certs = rustls_pemfile::certs(&mut &ca_pem[..]) - .collect::, _>>() - .expect("Failed to parse CA certificate PEM"); - let mut root_store = rustls::RootCertStore::empty(); - for cert in ca_certs { - root_store - .add(cert) - .expect("Failed to add CA certificate to root store"); - } - let verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(root_store)) - .build() - .expect("Failed to build client certificate verifier"); - builder - .with_client_cert_verifier(verifier) - .with_single_cert(certs, key) - .expect("Failed to build rustls ServerConfig with mTLS") - } else { - builder - .with_no_client_auth() - .with_single_cert(certs, key) - .expect("Failed to build rustls ServerConfig") - } -} - fn build_outgoing_http_client( config: &VolumeServerConfig, ) -> Result<(reqwest::Client, String), Box> { @@ -439,11 +396,12 @@ async fn run(config: VolumeServerConfig) -> Result<(), Box) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for TlsPolicyError {} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum GoTlsVersion { + Ssl3, + Tls10, + Tls11, + Tls12, + Tls13, +} + +pub fn build_rustls_server_config( + cert_path: &str, + key_path: &str, + ca_path: &str, + policy: &TlsPolicy, +) -> Result { + let cert_chain = read_cert_chain(cert_path)?; + let private_key = read_private_key(key_path)?; + let provider = build_crypto_provider(policy)?; + let versions = build_supported_versions(policy)?; + + let builder = ServerConfig::builder_with_provider(provider.clone()) + .with_protocol_versions(&versions) + .map_err(|e| TlsPolicyError(format!("invalid TLS version policy: {}", e)))?; + + let builder = if ca_path.is_empty() { + builder.with_no_client_auth() + } else { + let roots = read_root_store(ca_path)?; + let verifier = WebPkiClientVerifier::builder_with_provider(Arc::new(roots), provider) + .build() + .map_err(|e| TlsPolicyError(format!("build client verifier failed: {}", e)))?; + builder.with_client_cert_verifier(verifier) + }; + + builder + .with_single_cert(cert_chain, private_key) + .map_err(|e| TlsPolicyError(format!("build rustls server config failed: {}", e))) +} + +fn read_cert_chain(cert_path: &str) -> Result>, TlsPolicyError> { + let cert_pem = std::fs::read(cert_path).map_err(|e| { + TlsPolicyError(format!( + "Failed to read TLS cert file '{}': {}", + cert_path, e + )) + })?; + rustls_pemfile::certs(&mut &cert_pem[..]) + .collect::, _>>() + .map_err(|e| { + TlsPolicyError(format!( + "Failed to parse TLS cert PEM '{}': {}", + cert_path, e + )) + }) +} + +fn read_private_key(key_path: &str) -> Result, TlsPolicyError> { + let key_pem = std::fs::read(key_path).map_err(|e| { + TlsPolicyError(format!("Failed to read TLS key file '{}': {}", key_path, e)) + })?; + rustls_pemfile::private_key(&mut &key_pem[..]) + .map_err(|e| TlsPolicyError(format!("Failed to parse TLS key PEM '{}': {}", key_path, e)))? + .ok_or_else(|| TlsPolicyError(format!("No private key found in '{}'", key_path))) +} + +fn read_root_store(ca_path: &str) -> Result { + let ca_pem = std::fs::read(ca_path) + .map_err(|e| TlsPolicyError(format!("Failed to read TLS CA file '{}': {}", ca_path, e)))?; + let ca_certs = rustls_pemfile::certs(&mut &ca_pem[..]) + .collect::, _>>() + .map_err(|e| TlsPolicyError(format!("Failed to parse TLS CA PEM '{}': {}", ca_path, e)))?; + let mut roots = RootCertStore::empty(); + for cert in ca_certs { + roots + .add(cert) + .map_err(|e| TlsPolicyError(format!("Failed to add CA cert '{}': {}", ca_path, e)))?; + } + Ok(roots) +} + +fn build_crypto_provider(policy: &TlsPolicy) -> Result, TlsPolicyError> { + let mut provider = aws_lc_rs::default_provider(); + let cipher_suites = parse_cipher_suites(&provider.cipher_suites, &policy.cipher_suites)?; + if !cipher_suites.is_empty() { + provider.cipher_suites = cipher_suites; + } + Ok(Arc::new(provider)) +} + +pub fn build_supported_versions( + policy: &TlsPolicy, +) -> Result, TlsPolicyError> { + let min_version = parse_go_tls_version(&policy.min_version)?; + let max_version = parse_go_tls_version(&policy.max_version)?; + let versions = [&rustls::version::TLS13, &rustls::version::TLS12] + .into_iter() + .filter(|version| { + let current = go_tls_version_for_supported(version); + min_version.map(|min| current >= min).unwrap_or(true) + && max_version.map(|max| current <= max).unwrap_or(true) + }) + .collect::>(); + + if versions.is_empty() { + return Err(TlsPolicyError(format!( + "TLS version range min='{}' max='{}' is unsupported by rustls", + policy.min_version, policy.max_version + ))); + } + + Ok(versions) +} + +fn parse_go_tls_version(value: &str) -> Result, TlsPolicyError> { + match value.trim() { + "" => Ok(None), + "SSLv3" => Ok(Some(GoTlsVersion::Ssl3)), + "TLS 1.0" => Ok(Some(GoTlsVersion::Tls10)), + "TLS 1.1" => Ok(Some(GoTlsVersion::Tls11)), + "TLS 1.2" => Ok(Some(GoTlsVersion::Tls12)), + "TLS 1.3" => Ok(Some(GoTlsVersion::Tls13)), + other => Err(TlsPolicyError(format!("invalid TLS version {}", other))), + } +} + +fn parse_cipher_suites( + available: &[SupportedCipherSuite], + value: &str, +) -> Result, TlsPolicyError> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Ok(Vec::new()); + } + + trimmed + .split(',') + .map(|name| { + let suite = parse_cipher_suite_name(name.trim())?; + available + .iter() + .copied() + .find(|candidate| candidate.suite() == suite) + .ok_or_else(|| { + TlsPolicyError(format!( + "TLS cipher suite '{}' is unsupported by the Rust implementation", + name.trim() + )) + }) + }) + .collect() +} + +fn parse_cipher_suite_name(value: &str) -> Result { + match value { + "TLS_AES_128_GCM_SHA256" => Ok(CipherSuite::TLS13_AES_128_GCM_SHA256), + "TLS_AES_256_GCM_SHA384" => Ok(CipherSuite::TLS13_AES_256_GCM_SHA384), + "TLS_CHACHA20_POLY1305_SHA256" => Ok(CipherSuite::TLS13_CHACHA20_POLY1305_SHA256), + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" => { + Ok(CipherSuite::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256) + } + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384" => { + Ok(CipherSuite::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384) + } + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256" => { + Ok(CipherSuite::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256) + } + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" => { + Ok(CipherSuite::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256) + } + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384" => { + Ok(CipherSuite::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) + } + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256" => { + Ok(CipherSuite::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256) + } + other => Err(TlsPolicyError(format!( + "TLS cipher suite '{}' is unsupported by the Rust implementation", + other + ))), + } +} + +fn go_tls_version_for_supported(version: &SupportedProtocolVersion) -> GoTlsVersion { + match version.version { + rustls::ProtocolVersion::TLSv1_2 => GoTlsVersion::Tls12, + rustls::ProtocolVersion::TLSv1_3 => GoTlsVersion::Tls13, + _ => unreachable!("rustls only exposes TLS 1.2 and 1.3"), + } +} + +#[cfg(test)] +mod tests { + use super::{build_supported_versions, parse_cipher_suites, TlsPolicy}; + use rustls::crypto::aws_lc_rs; + + #[test] + fn test_build_supported_versions_defaults_to_tls12_and_tls13() { + let versions = build_supported_versions(&TlsPolicy::default()).unwrap(); + assert_eq!( + versions, + vec![&rustls::version::TLS13, &rustls::version::TLS12] + ); + } + + #[test] + fn test_build_supported_versions_filters_to_tls13() { + let versions = build_supported_versions(&TlsPolicy { + min_version: "TLS 1.3".to_string(), + max_version: "TLS 1.3".to_string(), + cipher_suites: String::new(), + }) + .unwrap(); + assert_eq!(versions, vec![&rustls::version::TLS13]); + } + + #[test] + fn test_build_supported_versions_rejects_unsupported_legacy_range() { + let err = build_supported_versions(&TlsPolicy { + min_version: "TLS 1.0".to_string(), + max_version: "TLS 1.1".to_string(), + cipher_suites: String::new(), + }) + .unwrap_err(); + assert!(err.to_string().contains("unsupported by rustls")); + } + + #[test] + fn test_parse_cipher_suites_accepts_go_names() { + let cipher_suites = parse_cipher_suites( + &aws_lc_rs::default_provider().cipher_suites, + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_AES_128_GCM_SHA256", + ) + .unwrap(); + assert_eq!(cipher_suites.len(), 2); + } +} diff --git a/seaweed-volume/src/server/grpc_client.rs b/seaweed-volume/src/server/grpc_client.rs index 8cb6872af..0bf668d5d 100644 --- a/seaweed-volume/src/server/grpc_client.rs +++ b/seaweed-volume/src/server/grpc_client.rs @@ -99,6 +99,7 @@ pub fn build_grpc_endpoint( mod tests { use super::{build_grpc_endpoint, grpc_endpoint_uri, load_outgoing_grpc_tls}; use crate::config::{NeedleMapKind, ReadMode, VolumeServerConfig}; + use crate::security::tls::TlsPolicy; fn sample_config() -> VolumeServerConfig { VolumeServerConfig { @@ -155,6 +156,7 @@ mod tests { grpc_cert_file: String::new(), grpc_key_file: String::new(), grpc_ca_file: String::new(), + tls_policy: TlsPolicy::default(), enable_write_queue: false, security_file: String::new(), }