mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 13:30:46 +02:00
* grpc: optional client_cert/client_key for outgoing mTLS connections * scaffold: list client_cert/client_key in each grpc section
369 lines
14 KiB
Rust
369 lines
14 KiB
Rust
use std::error::Error;
|
|
use std::fmt;
|
|
use std::time::Duration;
|
|
|
|
use hyper::http::Uri;
|
|
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity};
|
|
|
|
use crate::config::VolumeServerConfig;
|
|
|
|
pub const GRPC_MAX_MESSAGE_SIZE: usize = 1 << 30;
|
|
const GRPC_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(60);
|
|
const GRPC_KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(20);
|
|
const GRPC_INITIAL_WINDOW_SIZE: u32 = 16 * 1024 * 1024;
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct OutgoingGrpcTlsConfig {
|
|
cert_pem: String,
|
|
key_pem: String,
|
|
ca_pem: String,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct GrpcClientError(String);
|
|
|
|
impl fmt::Display for GrpcClientError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.write_str(&self.0)
|
|
}
|
|
}
|
|
|
|
impl Error for GrpcClientError {}
|
|
|
|
pub fn load_outgoing_grpc_tls(
|
|
config: &VolumeServerConfig,
|
|
) -> Result<Option<OutgoingGrpcTlsConfig>, GrpcClientError> {
|
|
// prefer a dedicated client certificate: CAs may issue certs with only one of the serverAuth/clientAuth EKUs
|
|
let (cert_file, key_file) = if !config.grpc_client_cert_file.is_empty()
|
|
&& !config.grpc_client_key_file.is_empty()
|
|
{
|
|
(&config.grpc_client_cert_file, &config.grpc_client_key_file)
|
|
} else {
|
|
if !config.grpc_client_cert_file.is_empty() || !config.grpc_client_key_file.is_empty() {
|
|
tracing::warn!("grpc.volume.client_cert and grpc.volume.client_key must both be set, falling back to grpc.volume.cert and grpc.volume.key");
|
|
}
|
|
(&config.grpc_cert_file, &config.grpc_key_file)
|
|
};
|
|
if cert_file.is_empty() || key_file.is_empty() || config.grpc_ca_file.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let cert_pem = std::fs::read_to_string(cert_file).map_err(|e| {
|
|
GrpcClientError(format!(
|
|
"Failed to read outgoing gRPC cert '{}': {}",
|
|
cert_file, e
|
|
))
|
|
})?;
|
|
let key_pem = std::fs::read_to_string(key_file).map_err(|e| {
|
|
GrpcClientError(format!(
|
|
"Failed to read outgoing gRPC key '{}': {}",
|
|
key_file, e
|
|
))
|
|
})?;
|
|
let ca_pem = std::fs::read_to_string(&config.grpc_ca_file).map_err(|e| {
|
|
GrpcClientError(format!(
|
|
"Failed to read outgoing gRPC CA '{}': {}",
|
|
config.grpc_ca_file, e
|
|
))
|
|
})?;
|
|
|
|
Ok(Some(OutgoingGrpcTlsConfig {
|
|
cert_pem,
|
|
key_pem,
|
|
ca_pem,
|
|
}))
|
|
}
|
|
|
|
pub fn grpc_endpoint_uri(grpc_host_port: &str, tls: Option<&OutgoingGrpcTlsConfig>) -> String {
|
|
let scheme = if tls.is_some() { "https" } else { "http" };
|
|
format!("{}://{}", scheme, grpc_host_port)
|
|
}
|
|
|
|
pub fn build_grpc_endpoint(
|
|
grpc_host_port: &str,
|
|
tls: Option<&OutgoingGrpcTlsConfig>,
|
|
) -> Result<Endpoint, GrpcClientError> {
|
|
let uri = grpc_endpoint_uri(grpc_host_port, tls);
|
|
let mut endpoint = Channel::from_shared(uri.clone())
|
|
.map_err(|e| GrpcClientError(format!("invalid gRPC endpoint {}: {}", uri, e)))?
|
|
.http2_keep_alive_interval(GRPC_KEEPALIVE_INTERVAL)
|
|
.keep_alive_timeout(GRPC_KEEPALIVE_TIMEOUT)
|
|
.keep_alive_while_idle(false)
|
|
.initial_stream_window_size(Some(GRPC_INITIAL_WINDOW_SIZE))
|
|
.initial_connection_window_size(Some(GRPC_INITIAL_WINDOW_SIZE))
|
|
.http2_adaptive_window(false);
|
|
|
|
if let Some(tls) = tls {
|
|
let parsed = uri
|
|
.parse::<Uri>()
|
|
.map_err(|e| GrpcClientError(format!("invalid gRPC endpoint {}: {}", uri, e)))?;
|
|
let host = parsed
|
|
.host()
|
|
.ok_or_else(|| GrpcClientError(format!("missing host in gRPC endpoint {}", uri)))?;
|
|
let tls_config = ClientTlsConfig::new()
|
|
.identity(Identity::from_pem(
|
|
tls.cert_pem.clone(),
|
|
tls.key_pem.clone(),
|
|
))
|
|
.ca_certificate(Certificate::from_pem(tls.ca_pem.clone()))
|
|
.domain_name(host.to_string());
|
|
endpoint = endpoint.tls_config(tls_config).map_err(|e| {
|
|
GrpcClientError(format!("configure gRPC TLS for {} failed: {}", uri, e))
|
|
})?;
|
|
}
|
|
|
|
Ok(endpoint)
|
|
}
|
|
|
|
/// Parse a SeaweedFS server address (`"ip:port.grpcPort"` or
|
|
/// `"ip:port"`) into the `host:grpcPort` form `build_grpc_endpoint`
|
|
/// expects. With the trailing `.grpcPort` segment, that segment IS
|
|
/// the gRPC port; without it, the gRPC port is `port + 10000`
|
|
/// (SeaweedFS's HTTP↔gRPC port-offset convention).
|
|
///
|
|
/// Shared between `grpc_server.rs` and the distributed-EC-read path
|
|
/// in `store_ec.rs` — keep this as the single source of truth so the
|
|
/// HTTP↔gRPC port translation can't drift between callers.
|
|
pub fn parse_grpc_address(source: &str) -> Result<String, String> {
|
|
let colon_idx = source
|
|
.rfind(':')
|
|
.ok_or_else(|| format!("cannot parse address: {}", source))?;
|
|
let host = &source[..colon_idx];
|
|
let port_part = &source[colon_idx + 1..];
|
|
|
|
if let Some(dot_idx) = port_part.rfind('.') {
|
|
// Format: "ip:port.grpcPort". Validate BOTH ports as u16
|
|
// so a malformed HTTP port (e.g. `host:abc.18080`) is
|
|
// rejected here rather than tripping a downstream
|
|
// `build_grpc_endpoint` URI parse failure with a less
|
|
// useful error.
|
|
let http_port = &port_part[..dot_idx];
|
|
let grpc_port = &port_part[dot_idx + 1..];
|
|
http_port
|
|
.parse::<u16>()
|
|
.map_err(|e| format!("invalid http port {:?}: {}", http_port, e))?;
|
|
grpc_port
|
|
.parse::<u16>()
|
|
.map_err(|e| format!("invalid grpc port {:?}: {}", grpc_port, e))?;
|
|
return Ok(format!("{}:{}", host, grpc_port));
|
|
}
|
|
|
|
// Format: "ip:port" → grpc = port + 10000. Reject inputs whose
|
|
// implicit grpc port would overflow the TCP port range (e.g.
|
|
// `host:60000` produces 70000 — invalid). Without this check
|
|
// the cast silently wraps and the endpoint call later fails
|
|
// with an opaque connection error.
|
|
let port: u16 = port_part
|
|
.parse()
|
|
.map_err(|e| format!("invalid port {:?}: {}", port_part, e))?;
|
|
let grpc_port = port as u32 + 10000;
|
|
if grpc_port > u16::MAX as u32 {
|
|
return Err(format!(
|
|
"implicit grpc port out of range: {} + 10000 = {}",
|
|
port, grpc_port
|
|
));
|
|
}
|
|
Ok(format!("{}:{}", host, grpc_port))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
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;
|
|
|
|
const TEST_CERT_PEM: &str = "-----BEGIN CERTIFICATE-----\nMIIBPDCB76ADAgECAhRuRPQgeAu43BT/M7EfAWSdapVdYDAFBgMrZXAwFDESMBAG\nA1UEAwwJbG9jYWxob3N0MB4XDTI2MDcwNTE2MTUyOVoXDTM2MDcwMjE2MTUyOVow\nFDESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEAr/3bNIFI+8V32oCiY6y+\nXRFmZpdNQ2g//VtRkT+nQg+jUzBRMB0GA1UdDgQWBBTsy9tLf1zPiXCQfgci6zNi\ndEzRSjAfBgNVHSMEGDAWgBTsy9tLf1zPiXCQfgci6zNidEzRSjAPBgNVHRMBAf8E\nBTADAQH/MAUGAytlcANBAIvsdw0IbvOBBkb9cd7BfMJfIP9pQQrAL03pCRWJFnFh\nSysaLVgFXI4T078IiaM874oO+iB+5vNbWEpc7CkGow4=\n-----END CERTIFICATE-----\n";
|
|
const TEST_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIHbyn71Kk+Y7KT3sBctit7uZpErpoH6qDbFj6P8qGaZH\n-----END PRIVATE KEY-----\n";
|
|
|
|
#[test]
|
|
fn test_build_grpc_endpoint_with_tls_resolves_crypto_provider() {
|
|
crate::security::tls::install_default_crypto_provider();
|
|
let tls = super::OutgoingGrpcTlsConfig {
|
|
cert_pem: TEST_CERT_PEM.to_string(),
|
|
key_pem: TEST_KEY_PEM.to_string(),
|
|
ca_pem: TEST_CERT_PEM.to_string(),
|
|
};
|
|
let endpoint = build_grpc_endpoint("127.0.0.1:19333", Some(&tls)).unwrap();
|
|
assert_eq!(endpoint.uri().scheme_str(), Some("https"));
|
|
}
|
|
|
|
fn sample_config() -> VolumeServerConfig {
|
|
VolumeServerConfig {
|
|
port: 8080,
|
|
grpc_port: 18080,
|
|
public_port: 8080,
|
|
ip: "127.0.0.1".to_string(),
|
|
bind_ip: String::new(),
|
|
public_url: "127.0.0.1:8080".to_string(),
|
|
id: String::new(),
|
|
masters: vec![],
|
|
pre_stop_seconds: 0,
|
|
idle_timeout: 0,
|
|
data_center: String::new(),
|
|
rack: String::new(),
|
|
index_type: NeedleMapKind::InMemory,
|
|
disk_type: String::new(),
|
|
folders: vec![],
|
|
folder_max_limits: vec![],
|
|
folder_tags: vec![],
|
|
min_free_spaces: vec![],
|
|
disk_types: vec![],
|
|
idx_folder: String::new(),
|
|
white_list: vec![],
|
|
fix_jpg_orientation: false,
|
|
read_mode: ReadMode::Local,
|
|
allow_untrusted_remote_endpoints: false,
|
|
cpu_profile: String::new(),
|
|
mem_profile: String::new(),
|
|
compaction_byte_per_second: 0,
|
|
maintenance_byte_per_second: 0,
|
|
file_size_limit_bytes: 0,
|
|
concurrent_upload_limit: 0,
|
|
concurrent_download_limit: 0,
|
|
inflight_upload_data_timeout: std::time::Duration::from_secs(0),
|
|
inflight_download_data_timeout: std::time::Duration::from_secs(0),
|
|
has_slow_read: false,
|
|
read_buffer_size_mb: 0,
|
|
ldb_timeout: 0,
|
|
pprof: false,
|
|
metrics_port: 0,
|
|
metrics_ip: String::new(),
|
|
debug: false,
|
|
debug_port: 0,
|
|
ui_enabled: false,
|
|
jwt_signing_key: vec![],
|
|
jwt_signing_expires_seconds: 0,
|
|
jwt_read_signing_key: vec![],
|
|
jwt_read_signing_expires_seconds: 0,
|
|
https_cert_file: String::new(),
|
|
https_key_file: String::new(),
|
|
https_ca_file: String::new(),
|
|
https_client_enabled: false,
|
|
https_client_cert_file: String::new(),
|
|
https_client_key_file: String::new(),
|
|
https_client_ca_file: String::new(),
|
|
grpc_cert_file: String::new(),
|
|
grpc_key_file: String::new(),
|
|
grpc_client_cert_file: String::new(),
|
|
grpc_client_key_file: String::new(),
|
|
grpc_ca_file: String::new(),
|
|
grpc_allowed_wildcard_domain: String::new(),
|
|
grpc_volume_allowed_common_names: vec![],
|
|
tls_policy: TlsPolicy::default(),
|
|
enable_write_queue: false,
|
|
security_file: String::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_grpc_endpoint_uri_uses_https_when_tls_enabled() {
|
|
let tls = super::OutgoingGrpcTlsConfig {
|
|
cert_pem: "cert".to_string(),
|
|
key_pem: "key".to_string(),
|
|
ca_pem: "ca".to_string(),
|
|
};
|
|
assert_eq!(
|
|
grpc_endpoint_uri("master.example.com:19333", Some(&tls)),
|
|
"https://master.example.com:19333"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_outgoing_grpc_tls_requires_cert_key_and_ca() {
|
|
let mut config = sample_config();
|
|
config.grpc_cert_file = "/tmp/client.pem".to_string();
|
|
assert!(load_outgoing_grpc_tls(&config).unwrap().is_none());
|
|
}
|
|
|
|
fn write_pem_files(dir: &tempfile::TempDir, config: &mut VolumeServerConfig) {
|
|
let write = |name: &str, content: &str| {
|
|
let path = dir.path().join(name);
|
|
std::fs::write(&path, content).unwrap();
|
|
path.to_str().unwrap().to_string()
|
|
};
|
|
config.grpc_cert_file = write("server.pem", "server-cert");
|
|
config.grpc_key_file = write("server.key", "server-key");
|
|
config.grpc_ca_file = write("ca.pem", "ca");
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_outgoing_grpc_tls_prefers_client_cert() {
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
let mut config = sample_config();
|
|
write_pem_files(&dir, &mut config);
|
|
let client_cert = dir.path().join("client.pem");
|
|
let client_key = dir.path().join("client.key");
|
|
std::fs::write(&client_cert, "client-cert").unwrap();
|
|
std::fs::write(&client_key, "client-key").unwrap();
|
|
config.grpc_client_cert_file = client_cert.to_str().unwrap().to_string();
|
|
config.grpc_client_key_file = client_key.to_str().unwrap().to_string();
|
|
|
|
let tls = load_outgoing_grpc_tls(&config).unwrap().unwrap();
|
|
assert_eq!(tls.cert_pem, "client-cert");
|
|
assert_eq!(tls.key_pem, "client-key");
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_outgoing_grpc_tls_falls_back_to_server_cert() {
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
let mut config = sample_config();
|
|
write_pem_files(&dir, &mut config);
|
|
|
|
let tls = load_outgoing_grpc_tls(&config).unwrap().unwrap();
|
|
assert_eq!(tls.cert_pem, "server-cert");
|
|
assert_eq!(tls.key_pem, "server-key");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_grpc_endpoint_without_tls_uses_http_scheme() {
|
|
let endpoint = build_grpc_endpoint("127.0.0.1:19333", None).unwrap();
|
|
assert_eq!(endpoint.uri().scheme_str(), Some("http"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_grpc_address_dotted_form() {
|
|
use super::parse_grpc_address;
|
|
assert_eq!(
|
|
parse_grpc_address("127.0.0.1:8080.18080").unwrap(),
|
|
"127.0.0.1:18080"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_grpc_address_implicit_form_adds_10000() {
|
|
use super::parse_grpc_address;
|
|
assert_eq!(
|
|
parse_grpc_address("127.0.0.1:8080").unwrap(),
|
|
"127.0.0.1:18080"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_grpc_address_rejects_non_numeric_http_port_in_dotted_form() {
|
|
use super::parse_grpc_address;
|
|
let err = parse_grpc_address("host:abc.18080").unwrap_err();
|
|
assert!(err.contains("invalid http port"), "{}", err);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_grpc_address_rejects_non_numeric_grpc_port_in_dotted_form() {
|
|
use super::parse_grpc_address;
|
|
let err = parse_grpc_address("host:8080.xyz").unwrap_err();
|
|
assert!(err.contains("invalid grpc port"), "{}", err);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_grpc_address_rejects_implicit_port_that_overflows() {
|
|
use super::parse_grpc_address;
|
|
let err = parse_grpc_address("127.0.0.1:60000").unwrap_err();
|
|
assert!(err.contains("out of range"), "{}", err);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_grpc_address_rejects_input_without_colon() {
|
|
use super::parse_grpc_address;
|
|
let err = parse_grpc_address("hostname").unwrap_err();
|
|
assert!(err.contains("cannot parse"), "{}", err);
|
|
}
|
|
}
|