Files
seaweedfs/seaweed-volume/src/server/volume_server.rs
T
Chris Lu 10cc06333b cluster: restrict Ping RPC to known peers of the requested type (#9445)
Ping previously dialled whatever host:port the caller asked for. Gate
each server's Ping handler on cluster membership: masters check the
topology, registered cluster nodes, and configured master peers; volume
servers only accept their seed/current masters; filers accept tracked
peer filers, the master-learned volume server set, and configured
masters.

Use address-indexed peer lookups to keep Ping target validation O(1):
- topology maintains a pb.ServerAddress -> *DataNode index alongside
  the dc/rack/node tree, kept in sync from doLinkChildNode and
  UnlinkChildNode plus the ip/port-rewrite branch in
  GetOrCreateDataNode. GetTopology now returns nil on a detached
  subtree instead of panicking, so the linkage hooks can no-op safely.
- vid_map tracks a refcount per volume-server address so
  hasVolumeServer answers without scanning every vid location. The
  add path skips empty-address entries the same way the delete path
  already does, so a zero-value Location cannot leak a permanent
  serverRefCount[""] bucket.
- masters reuse a cached master-address set from MasterClient instead
  of walking the configured peer slice on every request.
- volume servers compare against a pre-built seed-master set and
  protect currentMaster reads/writes with an RWMutex, fixing the
  data race with the heartbeat goroutine. The seed slice is copied
  on construction so external mutation cannot desync it from the
  frozen lookup set.
- cluster.check drops the direct volume-to-volume sweep; volume
  servers no longer carry a peer-volume list, and the note next to
  the dropped probe is reworded to make clear that direct
  volume-to-volume reachability is intentionally not validated by
  this command.

Update the volume-server integration tests that drove Ping through the
new admission gate: success-path coverage now targets the master peer
(the only type a volume server tracks), and the unknown/unreachable
path asserts the InvalidArgument the gate now returns instead of the
old downstream dial error.

Mirror the same admission gate in the Rust volume server crate: a
seed-master HashSet built once at startup plus a tokio RwLock over the
heartbeat-tracked current master, both consulted in is_known_ping_target
on every Ping, with InvalidArgument returned for any target that isn't
a recognised master.
2026-05-12 13:00:52 -07:00

541 lines
22 KiB
Rust

//! VolumeServer: the main HTTP server for volume operations.
//!
//! Routes:
//! GET/HEAD /{vid},{fid} — read a file
//! POST/PUT /{vid},{fid} — write a file
//! DELETE /{vid},{fid} — delete a file
//! GET /status — server status
//! GET /healthz — health check
//!
//! Matches Go's server/volume_server.go.
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering};
use std::sync::{Arc, RwLock};
use axum::{
extract::{connect_info::ConnectInfo, Request, State},
http::{header, HeaderValue, Method, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::{any, get},
Router,
};
use crate::config::ReadMode;
use crate::security::Guard;
use crate::storage::store::Store;
use super::grpc_client::OutgoingGrpcTlsConfig;
use super::handlers;
use super::write_queue::WriteQueue;
#[derive(Clone, Debug, Default)]
pub struct RuntimeMetricsConfig {
pub push_gateway: crate::metrics::PushGatewayConfig,
}
/// Shared state for the volume server.
pub struct VolumeServerState {
pub store: RwLock<Store>,
pub guard: RwLock<Guard>,
pub is_stopping: RwLock<bool>,
/// Maintenance mode flag.
pub maintenance: AtomicBool,
/// State version — incremented on each SetState call.
pub state_version: AtomicU32,
/// Throttling: concurrent upload/download limits (in bytes, 0 = disabled).
pub concurrent_upload_limit: i64,
pub concurrent_download_limit: i64,
pub inflight_upload_data_timeout: std::time::Duration,
pub inflight_download_data_timeout: std::time::Duration,
/// Current in-flight upload/download bytes.
pub inflight_upload_bytes: AtomicI64,
pub inflight_download_bytes: AtomicI64,
/// Notify waiters when inflight bytes decrease.
pub upload_notify: tokio::sync::Notify,
pub download_notify: tokio::sync::Notify,
/// Data center name from config.
pub data_center: String,
/// Rack name from config.
pub rack: String,
/// File size limit in bytes (0 = no limit).
pub file_size_limit_bytes: i64,
/// Default IO rate limit for maintenance copy/replication work.
pub maintenance_byte_per_second: i64,
/// Whether the server is connected to master (heartbeat active).
pub is_heartbeating: AtomicBool,
/// Whether master addresses are configured.
pub has_master: bool,
/// Seconds to wait before shutting down servers (graceful drain).
pub pre_stop_seconds: u32,
/// Notify heartbeat to send an immediate update when volume state changes.
pub volume_state_notify: tokio::sync::Notify,
/// Optional batched write queue for improved throughput under load.
pub write_queue: std::sync::OnceLock<WriteQueue>,
/// Registry of S3 tier backends for tiered storage operations.
pub s3_tier_registry: std::sync::RwLock<crate::remote_storage::s3_tier::S3TierRegistry>,
/// Read mode: local, proxy, or redirect for non-local volumes.
pub read_mode: ReadMode,
/// First master address for volume lookups (e.g., "localhost:9333").
pub master_url: String,
/// Seed master addresses for UI rendering.
pub master_urls: Vec<String>,
/// Canonical http `host:port` form of every configured seed master.
/// Built once at construction so Ping admission stays O(1). Mirrors
/// Go's `seedMasterSet` on `VolumeServer`.
pub seed_master_set: std::collections::HashSet<String>,
/// Current master this server is heartbeating with, in canonical http
/// `host:port` form. Empty when no heartbeat connection is active. The
/// heartbeat goroutine writes; admission reads — the lock keeps them
/// from racing on a leader change. Mirrors Go's `currentMaster` plus
/// `currentMasterLock`.
pub current_master_url: tokio::sync::RwLock<String>,
/// This server's own address (ip:port) for filtering self from lookup results.
pub self_url: String,
/// HTTP client for proxy requests and master lookups.
pub http_client: reqwest::Client,
/// Scheme used for outgoing master and peer HTTP requests ("http" or "https").
pub outgoing_http_scheme: String,
/// Optional client TLS material for outgoing gRPC connections.
pub outgoing_grpc_tls: Option<OutgoingGrpcTlsConfig>,
/// Metrics push settings learned from master heartbeat responses.
pub metrics_runtime: std::sync::RwLock<RuntimeMetricsConfig>,
pub metrics_notify: tokio::sync::Notify,
/// Whether JPEG uploads should be normalized using EXIF orientation.
pub fix_jpg_orientation: bool,
/// Read tuning flags for large-file streaming.
pub has_slow_read: bool,
pub read_buffer_size_bytes: usize,
/// Path to security.toml — stored for SIGHUP reload.
pub security_file: String,
/// Original CLI whitelist entries — stored for SIGHUP reload.
pub cli_white_list: Vec<String>,
/// Path to state.pb file for persisting VolumeServerState across restarts.
pub state_file_path: String,
}
impl VolumeServerState {
/// Check if the server is in maintenance mode; return gRPC error if so.
pub fn check_maintenance(&self) -> Result<(), tonic::Status> {
if self.maintenance.load(Ordering::Relaxed) {
let id = self.store.read().unwrap().id.clone();
return Err(tonic::Status::unavailable(format!(
"volume server {} is in maintenance mode",
id
)));
}
Ok(())
}
/// Build the seed master set from a list of raw `host:port[.grpcPort]`
/// addresses, normalised the same way Go's `pb.ServerAddress.ToHttpAddress`
/// does (drop the `.grpcPort` suffix, preserve everything else).
pub fn build_seed_master_set(master_urls: &[String]) -> std::collections::HashSet<String> {
master_urls
.iter()
.map(|m| to_http_address(m).into_owned())
.collect()
}
/// Returns true iff `target` (normalised to canonical http `host:port`)
/// is a master this server already knows about. Volume servers do not
/// keep a peer-volume or peer-filer list, so Ping is scoped to masters.
/// Mirrors Go's `VolumeServer.isKnownPingTarget`.
pub async fn is_known_ping_target(&self, target: &str, target_type: &str) -> bool {
if target_type != "master" {
return false;
}
let key = to_http_address(target).into_owned();
if key.is_empty() {
return false;
}
let current = self.current_master_url.read().await.clone();
if !current.is_empty() && current == key {
return true;
}
self.seed_master_set.contains(&key)
}
}
pub fn build_metrics_router() -> Router {
Router::new().route("/metrics", get(handlers::metrics_handler))
}
pub fn normalize_outgoing_http_url(scheme: &str, raw_target: &str) -> Result<String, String> {
if raw_target.starts_with("http://") || raw_target.starts_with("https://") {
let mut url = reqwest::Url::parse(raw_target)
.map_err(|e| format!("invalid url {}: {}", raw_target, e))?;
url.set_scheme(scheme)
.map_err(|_| format!("invalid scheme {}", scheme))?;
return Ok(url.to_string());
}
Ok(format!("{}://{}", scheme, raw_target))
}
/// Convert a SeaweedFS server address to its HTTP `host:port` form.
///
/// Mirrors Go's `pb.ServerAddress.ToHttpAddress()`: SeaweedFS encodes a
/// server's gRPC port by appending `.grpcPort` to the HTTP port (e.g.
/// `host:9333.19333`). For HTTP requests we want only the HTTP `host:port`.
/// - `host:port.grpcPort` -> `host:port`
/// - `host:port` -> `host:port` (unchanged)
/// - Anything that does not look like `host:port[.grpcPort]` is returned unchanged.
///
/// Returns a `Cow<str>` so the common (no-suffix) case borrows from `addr`
/// without allocating; only the rewrite branch produces a new `String`.
pub fn to_http_address(addr: &str) -> std::borrow::Cow<'_, str> {
let Some(ports_sep_index) = addr.rfind(':') else {
return std::borrow::Cow::Borrowed(addr);
};
let ports = &addr[ports_sep_index + 1..];
if let Some(dot_idx) = ports.rfind('.') {
let http_port = &ports[..dot_idx];
let grpc_port = &ports[dot_idx + 1..];
// Only strip the suffix when both parts parse as real ports — leave
// anything else (e.g. "host:abc.def") untouched so bad config surfaces
// rather than being silently rewritten. Mirrors the validation already
// done in `to_grpc_address` for the inverse direction.
if let (Ok(_), Ok(_)) = (http_port.parse::<u16>(), grpc_port.parse::<u16>()) {
return std::borrow::Cow::Owned(
addr[..ports_sep_index + 1 + dot_idx].to_string(),
);
}
}
std::borrow::Cow::Borrowed(addr)
}
fn request_remote_addr(request: &Request) -> Option<SocketAddr> {
request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|info| info.0)
}
fn request_is_whitelisted(state: &VolumeServerState, request: &Request) -> bool {
request_remote_addr(request)
.map(|remote_addr| {
state
.guard
.read()
.unwrap()
.check_whitelist(&remote_addr.to_string())
})
.unwrap_or(true)
}
/// Middleware: set Server header, echo x-amz-request-id, set CORS if Origin present.
async fn common_headers_middleware(request: Request, next: Next) -> Response {
let origin = request.headers().get("origin").cloned();
let request_id = super::request_id::generate_http_request_id();
let mut response =
super::request_id::scope_request_id(
request_id.clone(),
async move { next.run(request).await },
)
.await;
let headers = response.headers_mut();
if let Ok(val) = HeaderValue::from_str(crate::version::server_header()) {
headers.insert("Server", val);
}
if let Ok(val) = HeaderValue::from_str(&request_id) {
headers.insert("X-Request-Id", val.clone());
headers.insert("x-amz-request-id", val);
}
if origin.is_some() {
headers.insert("Access-Control-Allow-Origin", HeaderValue::from_static("*"));
headers.insert(
"Access-Control-Allow-Credentials",
HeaderValue::from_static("true"),
);
}
response
}
/// Admin store handler — dispatches based on HTTP method.
/// Matches Go's privateStoreHandler: GET/HEAD → read, POST/PUT → write,
/// DELETE → delete, OPTIONS → CORS headers, anything else → 400.
async fn admin_store_handler(state: State<Arc<VolumeServerState>>, request: Request) -> Response {
let start = std::time::Instant::now();
let method = request.method().clone();
let mut method_str = method.as_str().to_string();
let request_bytes = request
.headers()
.get(header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<i64>().ok())
.filter(|value| *value > 0)
.unwrap_or(0);
super::server_stats::record_request_open();
crate::metrics::INFLIGHT_REQUESTS_GAUGE
.with_label_values(&[&method_str])
.inc();
let whitelist_rejected = matches!(method, Method::POST | Method::PUT | Method::DELETE)
&& !request_is_whitelisted(&state, &request);
let response = match method.clone() {
_ if whitelist_rejected => StatusCode::UNAUTHORIZED.into_response(),
Method::GET | Method::HEAD => {
super::server_stats::record_read_request();
handlers::get_or_head_handler_from_request(state, request).await
}
Method::POST | Method::PUT => {
super::server_stats::record_write_request();
if request_bytes > 0 {
super::server_stats::record_bytes_in(request_bytes);
}
handlers::post_handler(state, request).await
}
Method::DELETE => {
super::server_stats::record_delete_request();
handlers::delete_handler(state, request).await
}
Method::OPTIONS => {
super::server_stats::record_read_request();
admin_options_response()
}
_ => {
let method_name = request.method().to_string();
let query = request.uri().query().map(|q| q.to_string());
method_str = "INVALID".to_string();
handlers::json_error_with_query(
StatusCode::BAD_REQUEST,
format!("unsupported method {}", method_name),
query.as_deref(),
)
}
};
if method == Method::GET {
if let Some(response_bytes) = response
.headers()
.get(header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<i64>().ok())
.filter(|value| *value > 0)
{
super::server_stats::record_bytes_out(response_bytes);
}
}
super::server_stats::record_request_close();
crate::metrics::INFLIGHT_REQUESTS_GAUGE
.with_label_values(&[&method_str])
.dec();
crate::metrics::REQUEST_COUNTER
.with_label_values(&[&method_str, response.status().as_str()])
.inc();
crate::metrics::REQUEST_DURATION
.with_label_values(&[&method_str])
.observe(start.elapsed().as_secs_f64());
response
}
/// Public store handler — dispatches based on HTTP method.
/// Matches Go's publicReadOnlyHandler: GET/HEAD → read, OPTIONS → CORS,
/// anything else → 200 (passthrough no-op).
async fn public_store_handler(state: State<Arc<VolumeServerState>>, request: Request) -> Response {
let start = std::time::Instant::now();
let method = request.method().clone();
let method_str = method.as_str().to_string();
super::server_stats::record_request_open();
crate::metrics::INFLIGHT_REQUESTS_GAUGE
.with_label_values(&[&method_str])
.inc();
let response = match method.clone() {
Method::GET | Method::HEAD => {
super::server_stats::record_read_request();
handlers::get_or_head_handler_from_request(state, request).await
}
Method::OPTIONS => {
super::server_stats::record_read_request();
public_options_response()
}
_ => StatusCode::OK.into_response(),
};
if method == Method::GET {
if let Some(response_bytes) = response
.headers()
.get(header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<i64>().ok())
.filter(|value| *value > 0)
{
super::server_stats::record_bytes_out(response_bytes);
}
}
super::server_stats::record_request_close();
crate::metrics::INFLIGHT_REQUESTS_GAUGE
.with_label_values(&[&method_str])
.dec();
crate::metrics::REQUEST_COUNTER
.with_label_values(&[&method_str, response.status().as_str()])
.inc();
crate::metrics::REQUEST_DURATION
.with_label_values(&[&method_str])
.observe(start.elapsed().as_secs_f64());
response
}
/// Build OPTIONS response for admin port.
fn admin_options_response() -> Response {
let mut response = StatusCode::OK.into_response();
let headers = response.headers_mut();
headers.insert(
"Access-Control-Allow-Methods",
HeaderValue::from_static("PUT, POST, GET, DELETE, OPTIONS"),
);
headers.insert(
"Access-Control-Allow-Headers",
HeaderValue::from_static("*"),
);
response
}
/// Build OPTIONS response for public port.
fn public_options_response() -> Response {
let mut response = StatusCode::OK.into_response();
let headers = response.headers_mut();
headers.insert(
"Access-Control-Allow-Methods",
HeaderValue::from_static("GET, OPTIONS"),
);
headers.insert(
"Access-Control-Allow-Headers",
HeaderValue::from_static("*"),
);
response
}
/// Build the admin (private) HTTP router — supports all operations.
/// UI route is only registered when no signing keys are configured,
/// matching Go's `if signingKey == "" || enableUiAccess` check.
pub fn build_admin_router(state: Arc<VolumeServerState>) -> Router {
let guard = state.guard.read().unwrap();
// This helper can only derive the default Go behavior from the guard state:
// UI stays enabled when the write signing key is empty. The explicit
// `access.ui` override is handled by `build_admin_router_with_ui(...)`.
let ui_enabled = guard.signing_key.0.is_empty();
drop(guard);
build_admin_router_with_ui(state, ui_enabled)
}
/// Build the admin router with an explicit UI exposure flag.
pub fn build_admin_router_with_ui(state: Arc<VolumeServerState>, ui_enabled: bool) -> Router {
let mut router = Router::new()
.route("/status", get(handlers::status_handler))
.route("/healthz", get(handlers::healthz_handler))
.route("/favicon.ico", get(handlers::favicon_handler))
.route(
"/seaweedfsstatic/*path",
get(handlers::static_asset_handler),
)
.route("/", any(admin_store_handler))
.route("/:path", any(admin_store_handler))
.route("/:vid/:fid", any(admin_store_handler))
.route("/:vid/:fid/:filename", any(admin_store_handler))
.fallback(admin_store_handler);
if ui_enabled {
// Note: /stats/* endpoints are commented out in Go's volume_server.go (L130-134).
// Only the UI endpoint is registered when UI access is enabled.
router = router.route("/ui/index.html", get(handlers::ui_handler));
}
router
.layer(middleware::from_fn(common_headers_middleware))
.with_state(state)
}
/// Build the public (read-only) HTTP router — only GET/HEAD.
pub fn build_public_router(state: Arc<VolumeServerState>) -> Router {
Router::new()
.route("/favicon.ico", get(handlers::favicon_handler))
.route(
"/seaweedfsstatic/*path",
get(handlers::static_asset_handler),
)
.route("/", any(public_store_handler))
.route("/:path", any(public_store_handler))
.route("/:vid/:fid", any(public_store_handler))
.route("/:vid/:fid/:filename", any(public_store_handler))
.fallback(public_store_handler)
.layer(middleware::from_fn(common_headers_middleware))
.with_state(state)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_to_http_address_strips_grpc_port_suffix() {
assert_eq!(to_http_address("10.0.0.1:9333.19333"), "10.0.0.1:9333");
assert_eq!(
to_http_address("master.local:9333.19333"),
"master.local:9333"
);
assert_eq!(to_http_address("10.85.183.6:5300.6300"), "10.85.183.6:5300");
}
#[test]
fn test_to_http_address_passthrough_without_grpc_suffix() {
assert_eq!(to_http_address("10.0.0.1:9333"), "10.0.0.1:9333");
assert_eq!(to_http_address("master.local:9333"), "master.local:9333");
}
#[test]
fn test_to_http_address_returns_input_when_unparseable() {
assert_eq!(to_http_address(""), "");
assert_eq!(to_http_address("no-port"), "no-port");
// Trailing colon: nothing after the separator, treat as unparseable.
assert_eq!(to_http_address("host:"), "host:");
}
#[test]
fn test_to_http_address_borrows_when_unchanged_and_owns_when_stripped() {
// The common case (no suffix) must not allocate.
let result = to_http_address("10.0.0.1:9333");
assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
// Stripping requires a new string.
let result = to_http_address("10.0.0.1:9333.19333");
assert!(matches!(result, std::borrow::Cow::Owned(_)));
assert_eq!(result, "10.0.0.1:9333");
// Unparseable / passthrough also borrows.
let result = to_http_address("host:abc.def");
assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
}
#[test]
fn test_to_http_address_keeps_non_numeric_dotted_suffix() {
// The dotted form is only valid when both sides are real port numbers.
// Otherwise the address is malformed config (e.g. a hostname like
// "host:abc.def"), and silently rewriting it would just hide the bug.
assert_eq!(to_http_address("host:abc.def"), "host:abc.def");
assert_eq!(to_http_address("host:9333.notaport"), "host:9333.notaport");
assert_eq!(to_http_address("host:notaport.19333"), "host:notaport.19333");
// Out-of-range ports must not be silently truncated either.
assert_eq!(to_http_address("host:99999.19333"), "host:99999.19333");
}
#[test]
fn test_to_http_address_handles_bracketed_ipv6_literals() {
// The function uses `rfind(':')`, so for bracketed IPv6 the port
// separator is correctly identified as the colon AFTER the closing
// bracket — making IPv4 and IPv6 behave the same.
assert_eq!(to_http_address("[::1]:9333.19333"), "[::1]:9333");
assert_eq!(
to_http_address("[2001:db8::10]:5300.6300"),
"[2001:db8::10]:5300"
);
// Plain bracketed IPv6 without a dotted suffix is borrowed unchanged.
let result = to_http_address("[2001:db8::1]:9333");
assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
assert_eq!(result, "[2001:db8::1]:9333");
// Non-numeric / out-of-range / missing suffix all preserve the input.
assert_eq!(to_http_address("[::1]:"), "[::1]:");
assert_eq!(to_http_address("[::1]:abc.def"), "[::1]:abc.def");
assert_eq!(to_http_address("[::1]:99999.19333"), "[::1]:99999.19333");
}
}