Compare commits

...

1 Commits

Author SHA1 Message Date
Chris a708820cda fix(rpc): recover tier reloads after peer capability revocation (#7920) 2026-09-16 02:25:52 +08:00
2 changed files with 212 additions and 8 deletions
+189
View File
@@ -1237,6 +1237,195 @@ mod tests {
clear_peer_capability(audience);
}
#[tokio::test]
async fn tier_reload_recovers_only_with_authenticated_replay_capability() {
use super::super::peer_rest_client::{PeerRestClient, TierConfigReloadOutcome};
use bytes::Bytes;
use http_body_util::StreamBody;
use hyper::{body::Frame, service::service_fn};
use hyper_util::rt::{TokioExecutor, TokioIo};
use rustfs_protos::proto_gen::node_service::{LoadTransitionTierConfigResponse, PingResponse};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use tonic_prost::prost::Message;
ensure_test_rpc_secret();
let server_epoch = crate::cluster::rpc::http_auth::tonic_rpc_boot_epoch();
for (proof, restarted) in [
("valid", false),
("valid", true),
("missing", false),
("missing", true),
("invalid", false),
("invalid", true),
("rejected", false),
("rejected", true),
] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind test peer");
let address = listener.local_addr().expect("test peer address");
let audience = address.to_string();
let client = PeerRestClient::new(
rustfs_utils::XHost {
name: "127.0.0.1".to_string(),
port: address.port(),
is_port_set: true,
},
format!("http://{address}"),
);
let old_epoch = if restarted { Uuid::new_v4() } else { server_epoch };
let pinned = PeerReplayState {
boot_epoch: Some(old_epoch),
cache_capability: Some(PeerReplayCapability::Capable { boot_epoch: old_epoch }),
};
set_peer_capability(&audience, pinned);
apply_peer_replay_response(audience.clone(), pinned, Err(std::io::Error::other("startup response omitted proof")));
let pings = Arc::new(AtomicUsize::new(0));
let reloads = Arc::new(AtomicUsize::new(0));
let server_audience = audience.clone();
let server_pings = pings.clone();
let server_reloads = reloads.clone();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("accept test client");
let service = service_fn(move |request: HttpRequest<hyper::body::Incoming>| {
let audience = server_audience.clone();
let pings = server_pings.clone();
let reloads = server_reloads.clone();
async move {
let authentication = crate::cluster::rpc::verify_tonic_rpc_signature_with_bootstrap(
&audience,
request.uri().path(),
request.headers(),
request.uri().path() == "/node_service.NodeService/Ping",
);
let stale_epoch = authentication
.as_ref()
.is_err_and(|error| error.to_string() == "RPC boot epoch is stale");
assert!(
authentication.is_ok() || stale_epoch,
"requests must retain valid signatures: {authentication:?}"
);
let challenge = tonic_boot_epoch_challenge(request.headers())
.expect("valid challenge")
.expect("challenge present");
let payload = match request.uri().path() {
"/node_service.NodeService/Ping" => {
pings.fetch_add(1, Ordering::SeqCst);
PingResponse {
version: 1,
body: Bytes::new(),
}
.encode_to_vec()
}
"/node_service.NodeService/LoadTransitionTierConfig" => {
reloads.fetch_add(1, Ordering::SeqCst);
assert!(
request.headers().contains_key(RPC_CONTENT_SHA256_HEADER),
"recovery must not downgrade body binding"
);
LoadTransitionTierConfigResponse {
success: true,
error_info: None,
error_code: None,
}
.encode_to_vec()
}
path => panic!("unexpected test RPC: {path}"),
};
let mut data = vec![0];
data.extend_from_slice(&u32::try_from(payload.len()).expect("small test protobuf").to_be_bytes());
data.extend_from_slice(&payload);
let mut trailers = http::HeaderMap::new();
trailers.insert(
"grpc-status",
if proof == "rejected" {
"16"
} else if stale_epoch {
"14"
} else {
"0"
}
.parse()
.expect("status header"),
);
if proof == "rejected" {
trailers.insert("grpc-message", "invalid credentials".parse().expect("message header"));
}
let frames: Vec<Result<Frame<Bytes>, std::convert::Infallible>> =
vec![Ok(Frame::data(Bytes::from(data))), Ok(Frame::trailers(trailers))];
let mut response = HttpResponse::new(StreamBody::new(futures::stream::iter(frames)));
response
.headers_mut()
.insert("content-type", "application/grpc".parse().expect("content type"));
if matches!(proof, "valid" | "invalid") {
let mut headers =
tonic_boot_epoch_response_headers(&audience, challenge).expect("sign response proof");
if proof == "invalid" {
headers.insert(
RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER,
"forged".parse().expect("forged proof header"),
);
}
response.headers_mut().extend(headers);
}
Ok::<_, std::convert::Infallible>(response)
}
});
hyper::server::conn::http2::Builder::new(TokioExecutor::new())
.serve_connection(TokioIo::new(stream), service)
.await
.expect("serve test peer");
});
let first =
tokio::time::timeout(std::time::Duration::from_secs(10), client.load_transition_tier_config_once_outcome())
.await
.expect("recovery attempt must finish");
assert_eq!(reloads.load(Ordering::SeqCst), 0, "revoked mutation must never reach the peer");
assert_eq!(
pings.load(Ordering::SeqCst),
1,
"revoked capability must trigger a signed read-only probe"
);
if proof == "rejected" {
assert!(
matches!(first, TierConfigReloadOutcome::Terminal(_)),
"invalid credentials must stay terminal"
);
} else {
assert!(
matches!(
first,
TierConfigReloadOutcome::TransientReconnect(_) | TierConfigReloadOutcome::TransientRetrySameChannel(_)
),
"a probe response must schedule another guarded reload"
);
let second =
tokio::time::timeout(std::time::Duration::from_secs(10), client.load_transition_tier_config_once_outcome())
.await
.expect("second reload attempt must finish");
if proof == "valid" {
assert!(matches!(second, TierConfigReloadOutcome::Success));
assert_eq!(reloads.load(Ordering::SeqCst), 1);
assert_eq!(pings.load(Ordering::SeqCst), 1);
assert_eq!(peer_replay_state(&audience).boot_epoch, Some(server_epoch));
} else {
assert!(matches!(
second,
TierConfigReloadOutcome::TransientReconnect(_) | TierConfigReloadOutcome::TransientRetrySameChannel(_)
));
assert_eq!(reloads.load(Ordering::SeqCst), 0, "unverified proof must not admit a mutation");
assert_eq!(pings.load(Ordering::SeqCst), 2);
assert_eq!(peer_replay_state(&audience).cache_capability, Some(PeerReplayCapability::Revoked));
}
}
server.abort();
let _ = server.await;
clear_peer_capability(&audience);
}
}
#[test]
fn test_signature_interceptor_binds_audience_from_peer_uri() {
let interceptor = TonicInterceptor::Signature(gen_tonic_signature_interceptor())
@@ -48,14 +48,14 @@ use rustfs_protos::proto_gen::node_service::{
GetPartitionsRequest, GetProcInfoRequest, GetSeLinuxInfoRequest, GetSysConfigRequest, GetSysErrorsRequest,
HealControlRequest, LoadBucketMetadataRequest, LoadGroupRequest, LoadPolicyMappingRequest, LoadPolicyRequest,
LoadRebalanceMetaRequest, LoadServiceAccountRequest, LoadTransitionTierConfigRequest, LoadUserRequest,
LocalStorageInfoRequest, Mss, ReloadPoolMetaRequest, ReloadSiteReplicationConfigRequest, ReplacementRecoveryStatusRequest,
ScannerActivityRequest, ScannerActivityResponse, ScannerDirtyUsageSnapshotRequest, ScannerDirtyUsageSnapshotResponse,
ScannerPublicationLeaseReleaseRequest, ScannerPublicationLeaseRequest, ScannerPublicationLeaseResponse,
ScannerScopedDirtyUsageAckRequest, ScannerScopedDirtyUsageAckResponse, ScannerScopedDirtyUsageEntry, ServerInfoRequest,
SignalServiceRequest, SignalServiceResponse, StartDecommissionRequest, StartProfilingRequest, StopRebalanceRequest,
TierDailyStatsRequest, TierMutationAbortRequest, TierMutationCommitRequest, TierMutationControlResponse,
TierMutationFailureClass, TierMutationPeerState, TierMutationPrepareRequest, node_service_client::NodeServiceClient,
tier_mutation_control_service_client::TierMutationControlServiceClient,
LocalStorageInfoRequest, Mss, PingRequest, ReloadPoolMetaRequest, ReloadSiteReplicationConfigRequest,
ReplacementRecoveryStatusRequest, ScannerActivityRequest, ScannerActivityResponse, ScannerDirtyUsageSnapshotRequest,
ScannerDirtyUsageSnapshotResponse, ScannerPublicationLeaseReleaseRequest, ScannerPublicationLeaseRequest,
ScannerPublicationLeaseResponse, ScannerScopedDirtyUsageAckRequest, ScannerScopedDirtyUsageAckResponse,
ScannerScopedDirtyUsageEntry, ServerInfoRequest, SignalServiceRequest, SignalServiceResponse, StartDecommissionRequest,
StartProfilingRequest, StopRebalanceRequest, TierDailyStatsRequest, TierMutationAbortRequest, TierMutationCommitRequest,
TierMutationControlResponse, TierMutationFailureClass, TierMutationPeerState, TierMutationPrepareRequest,
node_service_client::NodeServiceClient, tier_mutation_control_service_client::TierMutationControlServiceClient,
};
pub use rustfs_protos::{PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS};
use rustfs_protos::{TierMutationRpcPhase, evict_failed_connection};
@@ -2760,6 +2760,21 @@ impl PeerRestClient {
let response = match client.load_transition_tier_config(request).await {
Ok(response) => response.into_inner(),
Err(status)
if status.code() == tonic::Code::Unauthenticated && status.message() == "RPC peer replay capability changed" =>
{
// A restart can revoke the pinned capability. A signed read-only probe
// lets the peer prove its new epoch without bypassing the mutation guard.
let mut probe = Request::new(PingRequest {
version: 1,
body: Bytes::new(),
});
probe.set_timeout(rustfs_protos::heal_control_execution_timeout());
return match client.ping(probe).await {
Ok(_) => TierConfigReloadOutcome::TransientRetrySameChannel(status.into()),
Err(probe_status) => tier_config_reload_status_outcome(probe_status),
};
}
Err(status) => return tier_config_reload_status_outcome(status),
};
if !response.success {