mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-19 04:50:54 +02:00
* security: reload JWT signing keys on SIGHUP Signing keys were read once in the server constructors and never refreshed. After a key rotation (Secret update, divergent reads) the in-memory key stayed stale and every request kept failing "wrong jwt" until the affected process was restarted. Add Guard.UpdateSigningKeys and call it from the master, volume and filer reload paths and the s3 reload hook, next to the existing whitelist refresh. Make the global chunk-read JWT cache reloadable via an atomic swap, and register the master's Reload with grace.OnReload -- it was never wired, so the master ignored SIGHUP entirely. Mirror the same refresh in the Rust volume server's SIGHUP handler. * security: swap signing keys behind an atomic pointer Addresses review feedback on the in-place key swap: SigningKey is a []byte, so reassigning the Guard fields while a request handler reads them is a data race that can tear the multi-word slice header and read out of bounds. Hold the four signing-key fields in an immutable signingConfig snapshot behind atomic.Pointer; UpdateSigningKeys swaps the whole pointer, so a reader sees either the old keys or the new ones. Reads go through new SigningKey/ExpiresAfterSec/ReadSigningKey/ReadExpiresAfterSec accessors. The Rust guard is already safe: every read and the SIGHUP write go through the shared RwLock<Guard>. * security: fold whitelist + auth state into the atomic snapshot Review follow-up. UpdateSigningKeys still wrote isWriteActive while the request path read it (and the whitelist maps) unsynchronized, so a SIGHUP under load could expose an inconsistent mix of activation bits and whitelist contents. Move all hot-reloadable Guard state -- keys, expirations, whitelist, and the activation flags -- into a single immutable guardState swapped behind one atomic.Pointer. The Update* methods take a small mutex to serialize the read-modify-write; readers stay lock-free. The concurrency test now also rotates the whitelist and probes IsWhiteListed under -race. Also read each signing key once per branch in the volume/filer JWT auth checks, so a reload landing mid-check can't take the allow-fast-path after auth was enabled or verify against a different key than the branch saw.
1112 lines
40 KiB
Rust
1112 lines
40 KiB
Rust
use std::sync::{Arc, RwLock};
|
|
|
|
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, build_rustls_server_config_with_grpc_client_auth,
|
|
GrpcClientAuthPolicy, TlsPolicy,
|
|
};
|
|
use seaweed_volume::security::{Guard, SigningKey};
|
|
#[cfg(unix)]
|
|
use seaweed_volume::server::debug::build_debug_router;
|
|
use seaweed_volume::server::grpc_client::load_outgoing_grpc_tls;
|
|
use seaweed_volume::server::grpc_server::VolumeGrpcService;
|
|
#[cfg(unix)]
|
|
use seaweed_volume::server::profiling::CpuProfileSession;
|
|
use seaweed_volume::server::request_id::GrpcRequestIdLayer;
|
|
use seaweed_volume::server::volume_server::{
|
|
build_metrics_router, RuntimeMetricsConfig, VolumeServerState,
|
|
};
|
|
use seaweed_volume::server::write_queue::WriteQueue;
|
|
use seaweed_volume::storage::store::Store;
|
|
use seaweed_volume::storage::types::DiskType;
|
|
|
|
use tokio_rustls::TlsAcceptor;
|
|
|
|
#[cfg(unix)]
|
|
type CpuProfileParam = Option<CpuProfileSession>;
|
|
#[cfg(not(unix))]
|
|
type CpuProfileParam = Option<()>;
|
|
|
|
const GRPC_MAX_MESSAGE_SIZE: usize = 1 << 30;
|
|
const GRPC_KEEPALIVE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
|
|
const GRPC_KEEPALIVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
|
const GRPC_INITIAL_WINDOW_SIZE: u32 = 16 * 1024 * 1024;
|
|
const GRPC_MAX_HEADER_LIST_SIZE: u32 = 8 * 1024 * 1024;
|
|
const GRPC_MAX_CONCURRENT_STREAMS: u32 = 1000;
|
|
|
|
fn main() {
|
|
// Initialize tracing
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
|
)
|
|
.init();
|
|
|
|
let config = config::parse_cli();
|
|
seaweed_volume::server::server_stats::init_process_start();
|
|
#[cfg(unix)]
|
|
let cpu_profile = match CpuProfileSession::start(&config) {
|
|
Ok(session) => session,
|
|
Err(e) => {
|
|
error!("{}", e);
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
#[cfg(not(unix))]
|
|
let cpu_profile: Option<()> = None;
|
|
info!(
|
|
"SeaweedFS Volume Server (Rust) v{}",
|
|
seaweed_volume::version::full_version()
|
|
);
|
|
|
|
// Register Prometheus metrics
|
|
metrics::register_metrics();
|
|
|
|
// Build the tokio runtime and run the async entry point
|
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
|
.enable_all()
|
|
.build()
|
|
.expect("Failed to build tokio runtime");
|
|
|
|
if let Err(e) = rt.block_on(run(config, cpu_profile)) {
|
|
error!("Volume server failed: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
fn build_outgoing_http_client(
|
|
config: &VolumeServerConfig,
|
|
) -> Result<(reqwest::Client, String), Box<dyn std::error::Error>> {
|
|
let scheme = if config.https_client_enabled {
|
|
"https"
|
|
} else {
|
|
"http"
|
|
};
|
|
if !config.https_client_enabled {
|
|
return Ok((reqwest::Client::new(), scheme.to_string()));
|
|
}
|
|
|
|
let mut builder = reqwest::Client::builder();
|
|
if !config.https_client_ca_file.is_empty() {
|
|
let ca_pem = std::fs::read(&config.https_client_ca_file).map_err(|e| {
|
|
format!(
|
|
"Failed to read HTTPS client CA file '{}': {}",
|
|
config.https_client_ca_file, e
|
|
)
|
|
})?;
|
|
let cert = reqwest::Certificate::from_pem(&ca_pem).map_err(|e| {
|
|
format!(
|
|
"Failed to parse HTTPS client CA PEM '{}': {}",
|
|
config.https_client_ca_file, e
|
|
)
|
|
})?;
|
|
builder = builder.add_root_certificate(cert);
|
|
}
|
|
|
|
match (
|
|
config.https_client_cert_file.is_empty(),
|
|
config.https_client_key_file.is_empty(),
|
|
) {
|
|
(true, true) => {}
|
|
(false, false) => {
|
|
let cert_pem = std::fs::read(&config.https_client_cert_file).map_err(|e| {
|
|
format!(
|
|
"Failed to read HTTPS client cert file '{}': {}",
|
|
config.https_client_cert_file, e
|
|
)
|
|
})?;
|
|
let key_pem = std::fs::read(&config.https_client_key_file).map_err(|e| {
|
|
format!(
|
|
"Failed to read HTTPS client key file '{}': {}",
|
|
config.https_client_key_file, e
|
|
)
|
|
})?;
|
|
let mut identity_pem = cert_pem;
|
|
if !identity_pem.ends_with(b"\n") {
|
|
identity_pem.push(b'\n');
|
|
}
|
|
identity_pem.extend_from_slice(&key_pem);
|
|
let identity = reqwest::Identity::from_pem(&identity_pem).map_err(|e| {
|
|
format!(
|
|
"Failed to parse HTTPS client identity '{}'+ '{}': {}",
|
|
config.https_client_cert_file, config.https_client_key_file, e
|
|
)
|
|
})?;
|
|
builder = builder.identity(identity);
|
|
}
|
|
_ => {
|
|
return Err(format!(
|
|
"HTTPS client requires both cert and key, got cert='{}' key='{}'",
|
|
config.https_client_cert_file, config.https_client_key_file
|
|
)
|
|
.into());
|
|
}
|
|
}
|
|
|
|
Ok((builder.build()?, scheme.to_string()))
|
|
}
|
|
|
|
fn tls_policy_is_configured(policy: &TlsPolicy) -> bool {
|
|
!policy.min_version.is_empty()
|
|
|| !policy.max_version.is_empty()
|
|
|| !policy.cipher_suites.is_empty()
|
|
}
|
|
|
|
fn effective_http_tls_policy(ca_path: &str, configured_policy: &TlsPolicy) -> TlsPolicy {
|
|
if ca_path.is_empty() {
|
|
TlsPolicy::default()
|
|
} else {
|
|
configured_policy.clone()
|
|
}
|
|
}
|
|
|
|
fn build_grpc_server_tls_acceptor(
|
|
cert_path: &str,
|
|
key_path: &str,
|
|
ca_path: &str,
|
|
tls_policy: &TlsPolicy,
|
|
allowed_wildcard_domain: &str,
|
|
allowed_common_names: &[String],
|
|
) -> Option<TlsAcceptor> {
|
|
if cert_path.is_empty() || key_path.is_empty() || ca_path.is_empty() {
|
|
return None;
|
|
}
|
|
let client_auth_policy = GrpcClientAuthPolicy {
|
|
allowed_common_names: allowed_common_names.to_vec(),
|
|
allowed_wildcard_domain: allowed_wildcard_domain.to_string(),
|
|
};
|
|
let mut server_config = match build_rustls_server_config_with_grpc_client_auth(
|
|
cert_path,
|
|
key_path,
|
|
ca_path,
|
|
tls_policy,
|
|
&client_auth_policy,
|
|
) {
|
|
Ok(server_config) => server_config,
|
|
Err(e) => {
|
|
warn!("Failed to build gRPC TLS config: {}", e);
|
|
return None;
|
|
}
|
|
};
|
|
server_config.alpn_protocols = vec![b"h2".to_vec()];
|
|
Some(TlsAcceptor::from(Arc::new(server_config)))
|
|
}
|
|
|
|
fn build_http_server_tls_acceptor(
|
|
config: &VolumeServerConfig,
|
|
) -> Result<Option<TlsAcceptor>, Box<dyn std::error::Error>> {
|
|
if config.https_cert_file.is_empty() || config.https_key_file.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let effective_policy = effective_http_tls_policy(&config.https_ca_file, &config.tls_policy);
|
|
let tls_config = match build_rustls_server_config(
|
|
&config.https_cert_file,
|
|
&config.https_key_file,
|
|
&config.https_ca_file,
|
|
&effective_policy,
|
|
) {
|
|
Ok(tls_config) => tls_config,
|
|
Err(e)
|
|
if !config.https_ca_file.is_empty() && tls_policy_is_configured(&config.tls_policy) =>
|
|
{
|
|
warn!(
|
|
"Failed to apply HTTP TLS policy '{}', falling back to default rustls policy",
|
|
e
|
|
);
|
|
build_rustls_server_config(
|
|
&config.https_cert_file,
|
|
&config.https_key_file,
|
|
&config.https_ca_file,
|
|
&TlsPolicy::default(),
|
|
)?
|
|
}
|
|
Err(e) => return Err(e.into()),
|
|
};
|
|
|
|
Ok(Some(TlsAcceptor::from(Arc::new(tls_config))))
|
|
}
|
|
|
|
fn build_grpc_server_builder() -> tonic::transport::Server {
|
|
tonic::transport::Server::builder()
|
|
.http2_keepalive_interval(Some(GRPC_KEEPALIVE_INTERVAL))
|
|
.http2_keepalive_timeout(Some(GRPC_KEEPALIVE_TIMEOUT))
|
|
.max_concurrent_streams(Some(GRPC_MAX_CONCURRENT_STREAMS))
|
|
.initial_stream_window_size(Some(GRPC_INITIAL_WINDOW_SIZE))
|
|
.initial_connection_window_size(Some(GRPC_INITIAL_WINDOW_SIZE))
|
|
.http2_max_header_list_size(Some(GRPC_MAX_HEADER_LIST_SIZE))
|
|
}
|
|
|
|
fn build_volume_grpc_service(
|
|
grpc_service: VolumeGrpcService,
|
|
) -> VolumeServerServer<VolumeGrpcService> {
|
|
VolumeServerServer::new(grpc_service)
|
|
.max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE)
|
|
.max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE)
|
|
}
|
|
|
|
fn apply_idle_timeout<S>(
|
|
stream: S,
|
|
idle_timeout: std::time::Duration,
|
|
) -> std::pin::Pin<Box<tokio_io_timeout::TimeoutStream<S>>>
|
|
where
|
|
S: tokio::io::AsyncRead + tokio::io::AsyncWrite,
|
|
{
|
|
let mut stream = tokio_io_timeout::TimeoutStream::new(stream);
|
|
if !idle_timeout.is_zero() {
|
|
stream.set_read_timeout(Some(idle_timeout));
|
|
stream.set_write_timeout(Some(idle_timeout));
|
|
}
|
|
Box::pin(stream)
|
|
}
|
|
|
|
async fn run(
|
|
config: VolumeServerConfig,
|
|
#[allow(unused_variables)] cpu_profile: CpuProfileParam,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
// Initialize the store
|
|
let mut store = Store::new(config.index_type);
|
|
store.id = config.id.clone();
|
|
store.ip = config.ip.clone();
|
|
store.port = config.port;
|
|
store.grpc_port = config.grpc_port;
|
|
store.public_url = config.public_url.clone();
|
|
store.data_center = config.data_center.clone();
|
|
store.rack = config.rack.clone();
|
|
|
|
// Build shared state
|
|
let guard = Guard::new(
|
|
&config.white_list,
|
|
SigningKey(config.jwt_signing_key.clone()),
|
|
config.jwt_signing_expires_seconds,
|
|
SigningKey(config.jwt_read_signing_key.clone()),
|
|
config.jwt_read_signing_expires_seconds,
|
|
);
|
|
let master_url = config.masters.first().cloned().unwrap_or_default();
|
|
// Defensive-copy the configured seed masters before freezing the lookup
|
|
// set, so any later mutation of config.masters cannot desync them.
|
|
let master_urls: Vec<String> = config.masters.clone();
|
|
let seed_master_set = VolumeServerState::build_seed_master_set(&master_urls);
|
|
let self_url = format!("{}:{}", config.ip, config.port);
|
|
let (http_client, outgoing_http_scheme) = build_outgoing_http_client(&config)?;
|
|
let outgoing_grpc_tls = load_outgoing_grpc_tls(&config)?;
|
|
|
|
let security_file = config.security_file.clone();
|
|
let cli_white_list = config.white_list.clone();
|
|
|
|
let state = Arc::new(VolumeServerState {
|
|
store: RwLock::new(store),
|
|
guard: RwLock::new(guard),
|
|
is_stopping: RwLock::new(false),
|
|
maintenance: std::sync::atomic::AtomicBool::new(false),
|
|
state_version: std::sync::atomic::AtomicU32::new(0),
|
|
concurrent_upload_limit: config.concurrent_upload_limit,
|
|
concurrent_download_limit: config.concurrent_download_limit,
|
|
inflight_upload_data_timeout: config.inflight_upload_data_timeout,
|
|
inflight_download_data_timeout: config.inflight_download_data_timeout,
|
|
inflight_upload_bytes: std::sync::atomic::AtomicI64::new(0),
|
|
inflight_download_bytes: std::sync::atomic::AtomicI64::new(0),
|
|
upload_notify: tokio::sync::Notify::new(),
|
|
download_notify: tokio::sync::Notify::new(),
|
|
data_center: config.data_center.clone(),
|
|
rack: config.rack.clone(),
|
|
file_size_limit_bytes: config.file_size_limit_bytes,
|
|
maintenance_byte_per_second: config.maintenance_byte_per_second,
|
|
// Go sets isHeartbeating: true unconditionally at startup
|
|
is_heartbeating: std::sync::atomic::AtomicBool::new(true),
|
|
has_master: !config.masters.is_empty(),
|
|
pre_stop_seconds: config.pre_stop_seconds,
|
|
volume_state_notify: tokio::sync::Notify::new(),
|
|
write_queue: std::sync::OnceLock::new(),
|
|
s3_tier_registry: std::sync::RwLock::new(
|
|
seaweed_volume::remote_storage::s3_tier::S3TierRegistry::new(),
|
|
),
|
|
read_mode: config.read_mode,
|
|
master_url,
|
|
master_urls,
|
|
seed_master_set,
|
|
current_master_url: tokio::sync::RwLock::new(String::new()),
|
|
self_url,
|
|
http_client,
|
|
outgoing_http_scheme,
|
|
outgoing_grpc_tls,
|
|
metrics_runtime: std::sync::RwLock::new(RuntimeMetricsConfig::default()),
|
|
metrics_notify: tokio::sync::Notify::new(),
|
|
fix_jpg_orientation: config.fix_jpg_orientation,
|
|
has_slow_read: config.has_slow_read,
|
|
read_buffer_size_bytes: (config.read_buffer_size_mb.max(1) as usize) * 1024 * 1024,
|
|
security_file,
|
|
cli_white_list,
|
|
state_file_path: if config.folders.is_empty() {
|
|
String::new()
|
|
} else {
|
|
std::path::Path::new(&config.folders[0])
|
|
.join("state.pb")
|
|
.to_string_lossy()
|
|
.into_owned()
|
|
},
|
|
});
|
|
|
|
// Load persisted state from disk if it exists (matches Go's State.Load on startup)
|
|
if let Some(saved) =
|
|
seaweed_volume::server::grpc_server::load_state_file(&state.state_file_path)
|
|
{
|
|
state
|
|
.maintenance
|
|
.store(saved.maintenance, std::sync::atomic::Ordering::Relaxed);
|
|
state
|
|
.state_version
|
|
.store(saved.version, std::sync::atomic::Ordering::Relaxed);
|
|
}
|
|
|
|
if !config.masters.is_empty() {
|
|
let hb_config = seaweed_volume::server::heartbeat::HeartbeatConfig {
|
|
ip: config.ip.clone(),
|
|
port: config.port,
|
|
grpc_port: config.grpc_port,
|
|
public_url: config.public_url.clone(),
|
|
data_center: config.data_center.clone(),
|
|
rack: config.rack.clone(),
|
|
master_addresses: config.masters.clone(),
|
|
pulse_seconds: 5,
|
|
};
|
|
seaweed_volume::server::heartbeat::prime_master_configuration(&hb_config, &state).await;
|
|
}
|
|
|
|
{
|
|
let mut store = state.store.write().unwrap();
|
|
for (i, dir) in config.folders.iter().enumerate() {
|
|
let idx_dir = if config.idx_folder.is_empty() {
|
|
dir.as_str()
|
|
} else {
|
|
config.idx_folder.as_str()
|
|
};
|
|
let max_volumes = config.folder_max_limits[i];
|
|
let disk_type = DiskType::from_string(&config.disk_types[i]);
|
|
let tags = config.folder_tags.get(i).cloned().unwrap_or_default();
|
|
|
|
info!(
|
|
"Adding storage location: {} (max_volumes={}, disk_type={:?})",
|
|
dir, max_volumes, disk_type
|
|
);
|
|
let min_free_space = config.min_free_spaces[i].clone();
|
|
store
|
|
.add_location(dir, idx_dir, max_volumes, disk_type, min_free_space, tags)
|
|
.map_err(|e| format!("Failed to add storage location {}: {}", dir, e))?;
|
|
}
|
|
}
|
|
|
|
// Initialize the batched write queue if enabled
|
|
if config.enable_write_queue {
|
|
info!("Batched write queue enabled");
|
|
let wq = WriteQueue::new(state.clone(), 128);
|
|
let _ = state.write_queue.set(wq);
|
|
}
|
|
|
|
// Set initial metric gauges for concurrent limits and max volumes
|
|
metrics::CONCURRENT_UPLOAD_LIMIT.set(state.concurrent_upload_limit);
|
|
metrics::CONCURRENT_DOWNLOAD_LIMIT.set(state.concurrent_download_limit);
|
|
{
|
|
let store = state.store.read().unwrap();
|
|
let mut max_vols: i64 = 0;
|
|
for loc in &store.locations {
|
|
max_vols += loc
|
|
.max_volume_count
|
|
.load(std::sync::atomic::Ordering::Relaxed) as i64;
|
|
}
|
|
metrics::MAX_VOLUMES.set(max_vols);
|
|
}
|
|
|
|
// Run initial disk space check
|
|
{
|
|
let store = state.store.read().unwrap();
|
|
for loc in &store.locations {
|
|
loc.check_disk_space();
|
|
}
|
|
}
|
|
|
|
// Spawn background disk space monitor (checks every 60 seconds)
|
|
{
|
|
let monitor_state = state.clone();
|
|
tokio::spawn(async move {
|
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
|
|
interval.tick().await; // skip the first immediate tick
|
|
loop {
|
|
interval.tick().await;
|
|
let store = monitor_state.store.read().unwrap();
|
|
for loc in &store.locations {
|
|
loc.check_disk_space();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Build HTTP routers
|
|
#[allow(unused_mut)]
|
|
let mut admin_router = seaweed_volume::server::volume_server::build_admin_router_with_ui(
|
|
state.clone(),
|
|
config.ui_enabled,
|
|
);
|
|
#[cfg(unix)]
|
|
if config.pprof {
|
|
admin_router = admin_router.merge(build_debug_router());
|
|
}
|
|
let admin_addr = format!("{}:{}", config.bind_ip, config.port);
|
|
|
|
let public_port = config.public_port;
|
|
let needs_public = public_port != config.port;
|
|
let http_idle_timeout = std::time::Duration::from_secs(config.idle_timeout as u64);
|
|
|
|
let grpc_addr = format!("{}:{}", config.bind_ip, config.grpc_port);
|
|
let grpc_tls_acceptor = build_grpc_server_tls_acceptor(
|
|
&config.grpc_cert_file,
|
|
&config.grpc_key_file,
|
|
&config.grpc_ca_file,
|
|
&config.tls_policy,
|
|
&config.grpc_allowed_wildcard_domain,
|
|
&config.grpc_volume_allowed_common_names,
|
|
);
|
|
|
|
info!("Starting HTTP server on {}", admin_addr);
|
|
info!("Starting gRPC server on {}", grpc_addr);
|
|
if needs_public {
|
|
info!(
|
|
"Starting public HTTP server on {}:{}",
|
|
config.bind_ip, public_port
|
|
);
|
|
}
|
|
|
|
// Set up graceful shutdown via SIGINT/SIGTERM using broadcast channel
|
|
let (shutdown_tx, _) = tokio::sync::broadcast::channel::<()>(1);
|
|
|
|
let state_shutdown = state.clone();
|
|
let shutdown_tx_clone = shutdown_tx.clone();
|
|
tokio::spawn(async move {
|
|
let ctrl_c = tokio::signal::ctrl_c();
|
|
#[cfg(unix)]
|
|
{
|
|
let mut sigterm =
|
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
|
.expect("Failed to install SIGTERM handler");
|
|
tokio::select! {
|
|
_ = ctrl_c => { info!("Received SIGINT, shutting down..."); }
|
|
_ = sigterm.recv() => { info!("Received SIGTERM, shutting down..."); }
|
|
}
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
ctrl_c.await.ok();
|
|
info!("Received shutdown signal...");
|
|
}
|
|
*state_shutdown.is_stopping.write().unwrap() = true;
|
|
// Wake heartbeat loop immediately so it sends deregister heartbeat
|
|
// before the pre_stop delay (matches Go: StopHeartbeat() closes stopChan
|
|
// before sleeping preStopSeconds)
|
|
state_shutdown.volume_state_notify.notify_one();
|
|
|
|
// Graceful drain: wait pre_stop_seconds before shutting down servers
|
|
let pre_stop = state_shutdown.pre_stop_seconds;
|
|
if pre_stop > 0 {
|
|
info!("Pre-stop: waiting {} seconds before shutdown...", pre_stop);
|
|
tokio::time::sleep(std::time::Duration::from_secs(pre_stop as u64)).await;
|
|
}
|
|
|
|
let _ = shutdown_tx_clone.send(());
|
|
});
|
|
|
|
// Set up SIGHUP handler for config reload (mirrors Go's grace.OnReload)
|
|
#[cfg(unix)]
|
|
{
|
|
let state_reload = state.clone();
|
|
tokio::spawn(async move {
|
|
let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())
|
|
.expect("Failed to install SIGHUP handler");
|
|
loop {
|
|
sighup.recv().await;
|
|
info!("Received SIGHUP, reloading...");
|
|
|
|
// 1. Load new volumes from disk (Go's LoadNewVolumes)
|
|
{
|
|
info!("Loading new volume ids...");
|
|
let mut store = state_reload.store.write().unwrap();
|
|
store.load_new_volumes();
|
|
}
|
|
|
|
// 2. Reload security config (Go's Reload)
|
|
{
|
|
info!("Reloading security config...");
|
|
let sec = config::parse_security_config(&state_reload.security_file);
|
|
let mut whitelist = state_reload.cli_white_list.clone();
|
|
whitelist.extend(sec.guard_white_list.iter().cloned());
|
|
let mut guard = state_reload.guard.write().unwrap();
|
|
guard.update_whitelist(&whitelist);
|
|
guard.update_signing_keys(
|
|
SigningKey(sec.jwt_signing_key),
|
|
sec.jwt_signing_expires,
|
|
SigningKey(sec.jwt_read_signing_key),
|
|
sec.jwt_read_signing_expires,
|
|
);
|
|
}
|
|
|
|
// Trigger heartbeat to report new volumes
|
|
state_reload.volume_state_notify.notify_one();
|
|
info!("SIGHUP reload complete");
|
|
}
|
|
});
|
|
}
|
|
|
|
// Build optional TLS acceptor for HTTPS
|
|
let https_tls_acceptor =
|
|
if !config.https_cert_file.is_empty() && !config.https_key_file.is_empty() {
|
|
info!(
|
|
"TLS enabled for HTTP server (cert={}, key={})",
|
|
config.https_cert_file, config.https_key_file
|
|
);
|
|
build_http_server_tls_acceptor(&config)?
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Spawn all servers concurrently
|
|
let admin_listener = tokio::net::TcpListener::bind(&admin_addr)
|
|
.await
|
|
.unwrap_or_else(|e| panic!("Failed to bind HTTP to {}: {}", admin_addr, e));
|
|
let scheme = if https_tls_acceptor.is_some() {
|
|
"HTTPS"
|
|
} else {
|
|
"HTTP"
|
|
};
|
|
info!("{} server listening on {}", scheme, admin_addr);
|
|
|
|
let http_handle = if let Some(tls_acceptor) = https_tls_acceptor.clone() {
|
|
let mut shutdown_rx = shutdown_tx.subscribe();
|
|
tokio::spawn(async move {
|
|
serve_https(
|
|
admin_listener,
|
|
admin_router,
|
|
tls_acceptor,
|
|
http_idle_timeout,
|
|
async move {
|
|
let _ = shutdown_rx.recv().await;
|
|
},
|
|
)
|
|
.await;
|
|
})
|
|
} else {
|
|
let mut shutdown_rx = shutdown_tx.subscribe();
|
|
tokio::spawn(async move {
|
|
serve_http(
|
|
admin_listener,
|
|
admin_router,
|
|
http_idle_timeout,
|
|
async move {
|
|
let _ = shutdown_rx.recv().await;
|
|
},
|
|
)
|
|
.await;
|
|
})
|
|
};
|
|
|
|
// Bind the gRPC listener before spawning to propagate bind errors at startup.
|
|
let grpc_listener = tokio::net::TcpListener::bind(&grpc_addr)
|
|
.await
|
|
.unwrap_or_else(|e| panic!("Failed to bind gRPC to {}: {}", grpc_addr, e));
|
|
let grpc_local_addr = grpc_listener
|
|
.local_addr()
|
|
.unwrap_or_else(|e| panic!("Failed to get gRPC local addr: {}", e));
|
|
|
|
let grpc_handle = {
|
|
let grpc_state = state.clone();
|
|
let grpc_tls_acceptor = grpc_tls_acceptor.clone();
|
|
let mut shutdown_rx = shutdown_tx.subscribe();
|
|
let shutdown_tx_grpc = shutdown_tx.clone();
|
|
tokio::spawn(async move {
|
|
let grpc_service = VolumeGrpcService {
|
|
state: grpc_state.clone(),
|
|
};
|
|
let reflection_v1 = tonic_reflection::server::Builder::configure()
|
|
.register_encoded_file_descriptor_set(seaweed_volume::pb::FILE_DESCRIPTOR_SET)
|
|
.build_v1()
|
|
.expect("Failed to build gRPC reflection v1 service");
|
|
let reflection_v1alpha = tonic_reflection::server::Builder::configure()
|
|
.register_encoded_file_descriptor_set(seaweed_volume::pb::FILE_DESCRIPTOR_SET)
|
|
.build_v1alpha()
|
|
.expect("Failed to build gRPC reflection v1alpha service");
|
|
let result = if let Some(tls_acceptor) = grpc_tls_acceptor {
|
|
let incoming = grpc_tls_incoming(grpc_listener, tls_acceptor);
|
|
info!("gRPC server listening on {} (TLS enabled)", grpc_local_addr);
|
|
build_grpc_server_builder()
|
|
.layer(GrpcRequestIdLayer)
|
|
.add_service(reflection_v1)
|
|
.add_service(reflection_v1alpha)
|
|
.add_service(build_volume_grpc_service(grpc_service))
|
|
.serve_with_incoming_shutdown(incoming, async move {
|
|
let _ = shutdown_rx.recv().await;
|
|
})
|
|
.await
|
|
} else {
|
|
let incoming =
|
|
tokio_stream::wrappers::TcpListenerStream::new(grpc_listener);
|
|
info!("gRPC server listening on {}", grpc_local_addr);
|
|
build_grpc_server_builder()
|
|
.layer(GrpcRequestIdLayer)
|
|
.add_service(reflection_v1)
|
|
.add_service(reflection_v1alpha)
|
|
.add_service(build_volume_grpc_service(grpc_service))
|
|
.serve_with_incoming_shutdown(incoming, async move {
|
|
let _ = shutdown_rx.recv().await;
|
|
})
|
|
.await
|
|
};
|
|
if let Err(ref e) = result {
|
|
error!("gRPC server error: {}", e);
|
|
let _ = shutdown_tx_grpc.send(());
|
|
}
|
|
result
|
|
})
|
|
};
|
|
|
|
// Spawn heartbeat to master (if master addresses are configured)
|
|
let heartbeat_handle = {
|
|
let master_addrs = config.masters.clone();
|
|
if !master_addrs.is_empty() {
|
|
let hb_config = seaweed_volume::server::heartbeat::HeartbeatConfig {
|
|
ip: config.ip.clone(),
|
|
port: config.port,
|
|
grpc_port: config.grpc_port,
|
|
public_url: config.public_url.clone(),
|
|
data_center: config.data_center.clone(),
|
|
rack: config.rack.clone(),
|
|
master_addresses: master_addrs.clone(),
|
|
pulse_seconds: 5,
|
|
};
|
|
let hb_shutdown = shutdown_tx.subscribe();
|
|
let hb_state = state.clone();
|
|
info!("Will send heartbeats to master: {:?}", master_addrs);
|
|
Some(tokio::spawn(async move {
|
|
seaweed_volume::server::heartbeat::run_heartbeat_with_state(
|
|
hb_config,
|
|
hb_state,
|
|
hb_shutdown,
|
|
)
|
|
.await;
|
|
}))
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
|
|
let public_handle = if needs_public {
|
|
let public_router =
|
|
seaweed_volume::server::volume_server::build_public_router(state.clone());
|
|
let public_addr = format!("{}:{}", config.bind_ip, public_port);
|
|
let listener = tokio::net::TcpListener::bind(&public_addr)
|
|
.await
|
|
.unwrap_or_else(|e| panic!("Failed to bind public HTTP to {}: {}", public_addr, e));
|
|
info!("Public HTTP server listening on {}", public_addr);
|
|
let mut shutdown_rx = shutdown_tx.subscribe();
|
|
Some(tokio::spawn(async move {
|
|
serve_http(listener, public_router, http_idle_timeout, async move {
|
|
let _ = shutdown_rx.recv().await;
|
|
})
|
|
.await;
|
|
}))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let metrics_handle = if config.metrics_port > 0 {
|
|
let metrics_router = build_metrics_router();
|
|
let metrics_addr = format!("{}:{}", config.metrics_ip, config.metrics_port);
|
|
info!("Metrics server listening on {}", metrics_addr);
|
|
let listener = tokio::net::TcpListener::bind(&metrics_addr)
|
|
.await
|
|
.unwrap_or_else(|e| panic!("Failed to bind metrics HTTP to {}: {}", metrics_addr, e));
|
|
let mut shutdown_rx = shutdown_tx.subscribe();
|
|
Some(tokio::spawn(async move {
|
|
if let Err(e) = axum::serve(listener, metrics_router)
|
|
.with_graceful_shutdown(async move {
|
|
let _ = shutdown_rx.recv().await;
|
|
})
|
|
.await
|
|
{
|
|
error!("Metrics HTTP server error: {}", e);
|
|
}
|
|
}))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
#[cfg(unix)]
|
|
let debug_handle = if config.debug {
|
|
let debug_addr = format!("0.0.0.0:{}", config.debug_port);
|
|
info!("Debug pprof server listening on {}", debug_addr);
|
|
let listener = tokio::net::TcpListener::bind(&debug_addr)
|
|
.await
|
|
.unwrap_or_else(|e| panic!("Failed to bind debug HTTP to {}: {}", debug_addr, e));
|
|
let debug_router = build_debug_router();
|
|
let mut shutdown_rx = shutdown_tx.subscribe();
|
|
Some(tokio::spawn(async move {
|
|
if let Err(e) = axum::serve(listener, debug_router)
|
|
.with_graceful_shutdown(async move {
|
|
let _ = shutdown_rx.recv().await;
|
|
})
|
|
.await
|
|
{
|
|
error!("Debug HTTP server error: {}", e);
|
|
}
|
|
}))
|
|
} else {
|
|
None
|
|
};
|
|
#[cfg(not(unix))]
|
|
let debug_handle: Option<tokio::task::JoinHandle<()>> = None;
|
|
|
|
let metrics_push_handle = {
|
|
let push_state = state.clone();
|
|
let push_instance = format!("{}:{}", config.ip, config.port);
|
|
let push_shutdown = shutdown_tx.subscribe();
|
|
Some(tokio::spawn(async move {
|
|
run_metrics_push_loop(push_state, push_instance, push_shutdown).await;
|
|
}))
|
|
};
|
|
|
|
// Wait for servers. Use select! with &mut so the losing handle is not
|
|
// dropped, then await it explicitly afterward.
|
|
let mut server_err: Option<String> = None;
|
|
let mut http_handle = http_handle;
|
|
let mut grpc_handle = grpc_handle;
|
|
let grpc_finished_first = tokio::select! {
|
|
_ = &mut http_handle => false,
|
|
_ = &mut grpc_handle => true,
|
|
};
|
|
// Inspect the gRPC result (already resolved if it finished first,
|
|
// otherwise await it now).
|
|
let grpc_result = if grpc_finished_first {
|
|
grpc_handle.await
|
|
} else {
|
|
// HTTP finished first; gRPC is still running. Await it.
|
|
grpc_handle.await
|
|
};
|
|
match grpc_result {
|
|
Ok(Ok(())) => {}
|
|
Ok(Err(e)) => {
|
|
let msg = format!("gRPC server exited with error: {}", e);
|
|
error!("{}", msg);
|
|
server_err = Some(msg);
|
|
// serve error already sent shutdown inside the task
|
|
}
|
|
Err(e) => {
|
|
let msg = format!("gRPC task panicked: {}", e);
|
|
error!("{}", msg);
|
|
server_err = Some(msg);
|
|
let _ = shutdown_tx.send(());
|
|
}
|
|
}
|
|
// Ensure the HTTP handle completes too.
|
|
let _ = http_handle.await;
|
|
if let Some(h) = public_handle {
|
|
let _ = h.await;
|
|
}
|
|
if let Some(h) = metrics_handle {
|
|
let _ = h.await;
|
|
}
|
|
if let Some(h) = debug_handle {
|
|
let _ = h.await;
|
|
}
|
|
if let Some(h) = heartbeat_handle {
|
|
let _ = h.await;
|
|
}
|
|
if let Some(h) = metrics_push_handle {
|
|
let _ = h.await;
|
|
}
|
|
|
|
// Close all volumes (flush and release file handles) matching Go's Shutdown()
|
|
state.store.write().unwrap().close();
|
|
|
|
#[cfg(unix)]
|
|
if let Some(cpu_profile) = cpu_profile {
|
|
cpu_profile.finish().map_err(std::io::Error::other)?;
|
|
}
|
|
|
|
if let Some(err_msg) = server_err {
|
|
return Err(std::io::Error::other(err_msg).into());
|
|
}
|
|
|
|
info!("Volume server stopped.");
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_metrics_push_loop(
|
|
state: Arc<VolumeServerState>,
|
|
instance: String,
|
|
mut shutdown_rx: tokio::sync::broadcast::Receiver<()>,
|
|
) {
|
|
loop {
|
|
let push_cfg = { state.metrics_runtime.read().unwrap().push_gateway.clone() };
|
|
|
|
if push_cfg.address.is_empty() || push_cfg.interval_seconds == 0 {
|
|
tokio::select! {
|
|
_ = state.metrics_notify.notified() => continue,
|
|
_ = shutdown_rx.recv() => return,
|
|
}
|
|
}
|
|
|
|
if let Err(e) = metrics::push_metrics_once(
|
|
&state.http_client,
|
|
&push_cfg.address,
|
|
"volumeServer",
|
|
&instance,
|
|
)
|
|
.await
|
|
{
|
|
info!("could not push metrics to {}: {}", push_cfg.address, e);
|
|
}
|
|
|
|
let interval = std::time::Duration::from_secs(push_cfg.interval_seconds.max(1) as u64);
|
|
tokio::select! {
|
|
_ = tokio::time::sleep(interval) => {}
|
|
_ = state.metrics_notify.notified() => {}
|
|
_ = shutdown_rx.recv() => return,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn grpc_tls_incoming(
|
|
listener: tokio::net::TcpListener,
|
|
tls_acceptor: TlsAcceptor,
|
|
) -> impl tokio_stream::Stream<
|
|
Item = Result<tokio_rustls::server::TlsStream<tokio::net::TcpStream>, std::io::Error>,
|
|
> {
|
|
async_stream::stream! {
|
|
loop {
|
|
match listener.accept().await {
|
|
Ok((tcp_stream, remote_addr)) => match tls_acceptor.accept(tcp_stream).await {
|
|
Ok(tls_stream) => yield Ok(tls_stream),
|
|
Err(e) => {
|
|
tracing::debug!("gRPC TLS handshake failed from {}: {}", remote_addr, e);
|
|
}
|
|
},
|
|
Err(e) => {
|
|
yield Err(e);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Serve an axum Router over TLS using tokio-rustls.
|
|
/// Accepts TCP connections, performs TLS handshake, then serves HTTP over the encrypted stream.
|
|
async fn serve_http<F>(
|
|
tcp_listener: tokio::net::TcpListener,
|
|
app: axum::Router,
|
|
idle_timeout: std::time::Duration,
|
|
shutdown_signal: F,
|
|
) where
|
|
F: std::future::Future<Output = ()> + Send + 'static,
|
|
{
|
|
use hyper_util::rt::{TokioExecutor, TokioIo};
|
|
use hyper_util::server::conn::auto::Builder as HttpBuilder;
|
|
use hyper_util::service::TowerToHyperService;
|
|
use tower::Service;
|
|
|
|
let mut make_svc = app.into_make_service_with_connect_info::<std::net::SocketAddr>();
|
|
|
|
tokio::pin!(shutdown_signal);
|
|
|
|
loop {
|
|
tokio::select! {
|
|
_ = &mut shutdown_signal => {
|
|
info!("HTTP server shutting down");
|
|
break;
|
|
}
|
|
result = tcp_listener.accept() => {
|
|
match result {
|
|
Ok((tcp_stream, remote_addr)) => {
|
|
let tower_svc = make_svc.call(remote_addr).await.expect("infallible");
|
|
let hyper_svc = TowerToHyperService::new(tower_svc);
|
|
tokio::spawn(async move {
|
|
let io = TokioIo::new(apply_idle_timeout(tcp_stream, idle_timeout));
|
|
let builder = HttpBuilder::new(TokioExecutor::new());
|
|
if let Err(e) = builder.serve_connection(io, hyper_svc).await {
|
|
tracing::debug!("HTTP connection error: {}", e);
|
|
}
|
|
});
|
|
}
|
|
Err(e) => {
|
|
error!("Failed to accept TCP connection: {}", e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn serve_https<F>(
|
|
tcp_listener: tokio::net::TcpListener,
|
|
app: axum::Router,
|
|
tls_acceptor: TlsAcceptor,
|
|
idle_timeout: std::time::Duration,
|
|
shutdown_signal: F,
|
|
) where
|
|
F: std::future::Future<Output = ()> + Send + 'static,
|
|
{
|
|
use hyper_util::rt::{TokioExecutor, TokioIo};
|
|
use hyper_util::server::conn::auto::Builder as HttpBuilder;
|
|
use hyper_util::service::TowerToHyperService;
|
|
use tower::Service;
|
|
|
|
let mut make_svc = app.into_make_service_with_connect_info::<std::net::SocketAddr>();
|
|
|
|
tokio::pin!(shutdown_signal);
|
|
|
|
loop {
|
|
tokio::select! {
|
|
_ = &mut shutdown_signal => {
|
|
info!("HTTPS server shutting down");
|
|
break;
|
|
}
|
|
result = tcp_listener.accept() => {
|
|
match result {
|
|
Ok((tcp_stream, remote_addr)) => {
|
|
let tls_acceptor = tls_acceptor.clone();
|
|
let tower_svc = make_svc.call(remote_addr).await.expect("infallible");
|
|
let hyper_svc = TowerToHyperService::new(tower_svc);
|
|
tokio::spawn(async move {
|
|
match tls_acceptor.accept(tcp_stream).await {
|
|
Ok(tls_stream) => {
|
|
let io = TokioIo::new(apply_idle_timeout(tls_stream, idle_timeout));
|
|
let builder = HttpBuilder::new(TokioExecutor::new());
|
|
if let Err(e) = builder.serve_connection(io, hyper_svc).await {
|
|
tracing::debug!("HTTPS connection error: {}", e);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::debug!("TLS handshake failed: {}", e);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
Err(e) => {
|
|
error!("Failed to accept TCP connection: {}", e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
build_grpc_server_tls_acceptor, effective_http_tls_policy, tls_policy_is_configured,
|
|
};
|
|
use seaweed_volume::security::tls::TlsPolicy;
|
|
|
|
fn write_pem(dir: &tempfile::TempDir, name: &str, body: &str) -> String {
|
|
let path = dir.path().join(name);
|
|
std::fs::write(&path, body).unwrap();
|
|
path.to_string_lossy().into_owned()
|
|
}
|
|
|
|
#[test]
|
|
fn test_grpc_server_tls_requires_ca() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let cert = write_pem(
|
|
&dir,
|
|
"server.crt",
|
|
"-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n",
|
|
);
|
|
let key = write_pem(
|
|
&dir,
|
|
"server.key",
|
|
"-----BEGIN PRIVATE KEY-----\nZmFrZQ==\n-----END PRIVATE KEY-----\n",
|
|
);
|
|
|
|
assert!(
|
|
build_grpc_server_tls_acceptor(&cert, &key, "", &TlsPolicy::default(), "", &[])
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_grpc_server_tls_returns_none_when_files_are_missing() {
|
|
assert!(build_grpc_server_tls_acceptor(
|
|
"/missing/server.crt",
|
|
"/missing/server.key",
|
|
"/missing/ca.crt",
|
|
&TlsPolicy::default(),
|
|
"",
|
|
&[],
|
|
)
|
|
.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_grpc_server_tls_disables_on_unsupported_tls_policy() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let cert = write_pem(
|
|
&dir,
|
|
"server.crt",
|
|
"-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n",
|
|
);
|
|
let key = write_pem(
|
|
&dir,
|
|
"server.key",
|
|
"-----BEGIN PRIVATE KEY-----\nZmFrZQ==\n-----END PRIVATE KEY-----\n",
|
|
);
|
|
let ca = write_pem(
|
|
&dir,
|
|
"ca.crt",
|
|
"-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n",
|
|
);
|
|
|
|
assert!(build_grpc_server_tls_acceptor(
|
|
&cert,
|
|
&key,
|
|
&ca,
|
|
&TlsPolicy {
|
|
min_version: "TLS 1.0".to_string(),
|
|
max_version: "TLS 1.1".to_string(),
|
|
cipher_suites: String::new(),
|
|
},
|
|
"",
|
|
&[],
|
|
)
|
|
.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_effective_http_tls_policy_ignores_tls_policy_without_ca() {
|
|
let configured = TlsPolicy {
|
|
min_version: "TLS 1.3".to_string(),
|
|
max_version: "TLS 1.3".to_string(),
|
|
cipher_suites: "TLS_AES_128_GCM_SHA256".to_string(),
|
|
};
|
|
assert_eq!(
|
|
effective_http_tls_policy("", &configured),
|
|
TlsPolicy::default()
|
|
);
|
|
assert_eq!(
|
|
effective_http_tls_policy("/etc/seaweedfs/http-ca.pem", &configured),
|
|
configured
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_tls_policy_is_configured_detects_non_empty_fields() {
|
|
assert!(!tls_policy_is_configured(&TlsPolicy::default()));
|
|
assert!(tls_policy_is_configured(&TlsPolicy {
|
|
min_version: "TLS 1.2".to_string(),
|
|
max_version: String::new(),
|
|
cipher_suites: String::new(),
|
|
}));
|
|
}
|
|
}
|