Compare commits

..

1 Commits

Author SHA1 Message Date
overtrue 0c4d470795 fix(targets): explain webhook outbound allowlist failures 2026-08-02 16:15:38 +08:00
7 changed files with 168 additions and 112 deletions
@@ -1823,20 +1823,12 @@ pub(crate) async fn schedule_replication<S: ReplicationStorage>(
dsc: ReplicateDecision,
op_type: ReplicationType,
) {
let (synchronous, asynchronous) = dsc.partition_by_sync();
let mut async_oi = oi;
let synchronous = dsc.is_synchronous();
let ri = replicate_object_info_from_object_info(oi, dsc, op_type);
if synchronous.replicate_any() {
let ri = replicate_object_info_from_object_info(async_oi.clone(), synchronous, op_type);
let state = replicate_object(ri, o.clone()).await;
async_oi.replication_status_internal = state.replication_status_internal;
async_oi.version_purge_status_internal = state.version_purge_status_internal;
}
if asynchronous.replicate_any()
&& let Some(pool) = runtime_sources::replication_pool()
{
let ri = replicate_object_info_from_object_info(async_oi, asynchronous, op_type);
if synchronous {
replicate_object(ri, o).await
} else if let Some(pool) = runtime_sources::replication_pool() {
let _ = pool.queue_replica_task(ri).await;
}
}
@@ -19,7 +19,7 @@ use super::replication_error_boundary::{Result, is_err_object_not_found, is_err_
use super::replication_event_sink::{EventArgs, send_event, send_local_event};
use super::replication_filemeta_boundary::{
NULL_VERSION_ID, REPLICATE_EXISTING, REPLICATE_EXISTING_DELETE, ReplicateDecision, ReplicateObjectInfo, ReplicatedInfos,
ReplicatedTargetInfo, ReplicationAction, ReplicationState, ReplicationStatusType, ReplicationType, VersionPurgeStatusType,
ReplicatedTargetInfo, ReplicationAction, ReplicationStatusType, ReplicationType, VersionPurgeStatusType,
get_replication_state, parse_replicate_decision, replication_statuses_map, target_reset_header, version_purge_statuses_map,
};
use super::replication_lock_boundary::ReplicationLockTiming;
@@ -2001,14 +2001,61 @@ async fn replicate_delete_to_target(dobj: &DeletedObjectReplicationInfo, tgt_cli
rinfo
}
pub async fn replicate_object<S: ReplicationStorage>(roi: ReplicateObjectInfo, storage: Arc<S>) -> ReplicationState {
pub async fn replicate_object<S: ReplicationStorage>(roi: ReplicateObjectInfo, storage: Arc<S>) {
let bucket = roi.bucket.clone();
let object = roi.name.clone();
// The admission decision is the target-granular contract. Re-evaluating the
// live config here could fan a synchronous request out to targets that were
// not admitted, or promote an async target after a mixed-mode split.
let tgt_arns = roi.dsc.replicate_target_arns();
let cfg = match get_replication_config(&bucket).await {
Ok(Some(config)) => config,
Ok(None) => {
debug!(
event = EVENT_RESYNC_CONFIG_LOOKUP_SKIPPED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
bucket = %bucket,
reason = "replication_config_missing",
"Skipping replication object because replication config is missing"
);
send_local_event(EventArgs {
event_name: EventName::ObjectReplicationNotTracked.to_string(),
bucket_name: bucket.clone(),
object: roi.to_object_info(),
user_agent: "Internal: [Replication]".to_string(),
..Default::default()
});
return;
}
Err(err) => {
error!(
event = EVENT_RESYNC_CONFIG_LOOKUP_SKIPPED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
bucket = %bucket,
reason = "replication_config_lookup_failed",
error = %err,
"Failed to look up replication config for object replication"
);
send_local_event(EventArgs {
event_name: EventName::ObjectReplicationNotTracked.to_string(),
bucket_name: bucket.clone(),
object: roi.to_object_info(),
user_agent: "Internal: [Replication]".to_string(),
..Default::default()
});
return;
}
};
let tgt_arns = cfg.filter_target_arns(&ObjectOpts {
name: object.clone(),
user_tags: roi.user_tags.clone(),
ssec: roi.ssec,
op_type: roi.op_type,
// ExistingObject ops must respect per-rule ExistingObjectReplicationStatus.
// Heal ops intentionally bypass it (repairing a past failure is not an initial sync).
existing_object: roi.op_type == ReplicationType::ExistingObject,
..Default::default()
});
// Acquire a per-object namespace lock so that at most one worker (across all cluster
// nodes and MRF retry goroutines) replicates this object version at a time.
@@ -2033,7 +2080,7 @@ pub async fn replicate_object<S: ReplicationStorage>(roi: ReplicateObjectInfo, s
user_agent: "Internal: [Replication]".to_string(),
..Default::default()
});
return roi.replication_state.unwrap_or_default();
return;
}
};
let _obj_lock_guard = match obj_ns_lock.get_write_lock(ReplicationLockTiming::acquire_timeout()).await {
@@ -2056,7 +2103,7 @@ pub async fn replicate_object<S: ReplicationStorage>(roi: ReplicateObjectInfo, s
user_agent: "Internal: [Replication]".to_string(),
..Default::default()
});
return roi.replication_state.unwrap_or_default();
return;
}
};
@@ -2132,10 +2179,8 @@ pub async fn replicate_object<S: ReplicationStorage>(roi: ReplicateObjectInfo, s
}
}
let previous_state = roi.replication_state.clone().unwrap_or_default();
let merged_state = get_replication_state(&rinfos, &previous_state, roi.version_id.map(|v| v.to_string()));
let replication_status = merged_state.composite_replication_status();
let new_replication_internal = merged_state.replication_status_internal.clone();
let replication_status = rinfos.replication_status();
let new_replication_internal = rinfos.replication_status_internal();
let mut object_info = roi.to_object_info();
if roi.replication_status_internal != new_replication_internal || rinfos.replication_resynced() {
@@ -2204,8 +2249,6 @@ pub async fn replicate_object<S: ReplicationStorage>(roi: ReplicateObjectInfo, s
}
}
}
merged_state
}
trait ReplicateObjectInfoExt {
+67 -1
View File
@@ -21,7 +21,7 @@ use crate::config::{
redacted_secret, redacted_secret_option,
};
use crate::service_manager::KmsServiceStatus;
use crate::types::KeyMetadata;
use crate::types::{KeyMetadata, KeyUsage};
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashMap;
use std::fmt;
@@ -1300,6 +1300,49 @@ mod tests {
// Key Management API Types
// ========================================
/// Request to create a new key with optional custom name
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateKeyRequest {
/// Custom key name (optional, will auto-generate UUID if not provided)
pub key_name: Option<String>,
/// Key usage type
pub key_usage: KeyUsage,
/// Key description
pub description: Option<String>,
/// Key policy JSON string
pub policy: Option<String>,
/// Tags for the key
pub tags: HashMap<String, String>,
/// Origin of the key
pub origin: Option<String>,
}
impl Default for CreateKeyRequest {
fn default() -> Self {
Self {
key_name: None,
key_usage: KeyUsage::EncryptDecrypt,
description: None,
policy: None,
tags: HashMap::new(),
origin: None,
}
}
}
/// Response from create key operation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateKeyResponse {
/// Success flag
pub success: bool,
/// Status message
pub message: String,
/// Created key ID (either custom name or auto-generated UUID)
pub key_id: String,
/// Key metadata
pub key_metadata: KeyMetadata,
}
/// JSON shape returned by the admin delete-key endpoint.
///
/// The delete *request* shape lives in [`crate::types::DeleteKeyRequest`] —
@@ -1317,6 +1360,15 @@ pub struct DeleteKeyResponse {
pub deletion_date: Option<String>,
}
/// Request to list all keys
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListKeysRequest {
/// Maximum number of keys to return (1-1000)
pub limit: Option<u32>,
/// Pagination marker
pub marker: Option<String>,
}
/// Response from list keys operation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListKeysResponse {
@@ -1332,6 +1384,13 @@ pub struct ListKeysResponse {
pub next_marker: Option<String>,
}
/// Request to describe a key
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DescribeKeyRequest {
/// Key ID to describe
pub key_id: String,
}
/// Response from describe key operation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DescribeKeyResponse {
@@ -1343,6 +1402,13 @@ pub struct DescribeKeyResponse {
pub key_metadata: Option<KeyMetadata>,
}
/// Request to cancel key deletion
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CancelKeyDeletionRequest {
/// Key ID to cancel deletion for
pub key_id: String,
}
/// Response from cancel key deletion operation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CancelKeyDeletionResponse {
-47
View File
@@ -660,27 +660,6 @@ impl ReplicateDecision {
self.targets_map.values().any(|t| t.synchronous)
}
/// Split admitted targets by their configured delivery mode.
///
/// Non-replicating entries are intentionally omitted from both decisions.
/// Callers must not promote an async target merely because another target is
/// synchronous, and unsupported operation paths can keep both partitions
/// empty or explicitly async.
pub fn partition_by_sync(&self) -> (Self, Self) {
let mut synchronous = Self::new();
let mut asynchronous = Self::new();
for target in self.targets_map.values().filter(|target| target.replicate) {
if target.synchronous {
synchronous.set(target.clone());
} else {
asynchronous.set(target.clone());
}
}
(synchronous, asynchronous)
}
/// Updates ReplicateDecision with target's replication decision
pub fn set(&mut self, target: ReplicateTargetDecision) {
self.targets_map.insert(target.arn.clone(), target);
@@ -1088,32 +1067,6 @@ mod tests {
assert_eq!(entry.target_arns, vec!["arn:target-a".to_string()]);
}
#[test]
fn partition_by_sync_keeps_mixed_targets_independent() {
let mut decision = ReplicateDecision::new();
decision.set(ReplicateTargetDecision::new("arn:sync".to_string(), true, true));
decision.set(ReplicateTargetDecision::new("arn:async".to_string(), true, false));
decision.set(ReplicateTargetDecision::new("arn:disabled".to_string(), false, true));
let (synchronous, asynchronous) = decision.partition_by_sync();
assert_eq!(synchronous.replicate_target_arns(), vec!["arn:sync".to_string()]);
assert_eq!(asynchronous.replicate_target_arns(), vec!["arn:async".to_string()]);
assert!(synchronous.is_synchronous());
assert!(!asynchronous.is_synchronous());
}
#[test]
fn partition_by_sync_does_not_promote_async_targets() {
let mut decision = ReplicateDecision::new();
decision.set(ReplicateTargetDecision::new("arn:async".to_string(), true, false));
let (synchronous, asynchronous) = decision.partition_by_sync();
assert!(!synchronous.replicate_any());
assert_eq!(asynchronous.replicate_target_arns(), vec!["arn:async".to_string()]);
}
#[test]
fn target_state_reads_resync_timestamp_from_target_reset_header_key() {
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
+37 -3
View File
@@ -23,7 +23,7 @@ use rustfs_config::{
NATS_TLS_CLIENT_KEY, NATS_TOKEN, NATS_USERNAME, PULSAR_AUTH_TOKEN, PULSAR_PASSWORD, PULSAR_QUEUE_DIR, PULSAR_TLS_CA,
PULSAR_TOPIC, PULSAR_USERNAME,
};
use rustfs_utils::egress::OutboundPolicy;
use rustfs_utils::egress::{ENV_OUTBOUND_ALLOW_ORIGINS, OutboundPolicy, OutboundPolicyError};
use std::collections::HashSet;
use std::path::Path;
use std::str::FromStr;
@@ -223,12 +223,26 @@ pub(super) fn validate_outbound_http_url(value: &Url, field_label: &str) -> Resu
OutboundPolicy::from_env_cached().map_err(|err| TargetError::Configuration(format!("invalid outbound policy: {err}")))?;
policy
.validate_url(value)
.map_err(|e| TargetError::Configuration(format!("{field_label} is not allowed: {e}")))
.map_err(|err| TargetError::Configuration(format_outbound_http_url_error(field_label, value, err)))
}
fn format_outbound_http_url_error(field_label: &str, value: &Url, err: OutboundPolicyError) -> String {
let base = format!("{field_label} is not allowed: {err}");
if matches!(err, OutboundPolicyError::ForbiddenHost { .. }) {
let origin = value.origin().ascii_serialization();
return format!(
"{base}; set {ENV_OUTBOUND_ALLOW_ORIGINS}={origin} to allow this operator-owned endpoint (origin only, no path)"
);
}
base
}
#[cfg(test)]
mod tests {
use super::{parse_jetstream_enable, parse_url, validate_nats_server_config, validate_pulsar_broker_config};
use super::{
format_outbound_http_url_error, parse_jetstream_enable, parse_url, validate_nats_server_config,
validate_pulsar_broker_config,
};
use async_nats::ServerAddr;
use rustfs_config::server_config::KVS;
use rustfs_config::{
@@ -236,7 +250,9 @@ mod tests {
NATS_SUBJECT, NATS_TOKEN, NATS_USERNAME, PULSAR_TLS_ALLOW_INSECURE, PULSAR_TLS_CA, PULSAR_TLS_HOSTNAME_VERIFICATION,
PULSAR_TOPIC,
};
use rustfs_utils::egress::{ENV_OUTBOUND_ALLOW_ORIGINS, OutboundPolicyError};
use std::str::FromStr;
use url::Url;
fn nats_server() -> ServerAddr {
ServerAddr::from_str("nats://127.0.0.1:4222").expect("valid nats address")
@@ -248,6 +264,24 @@ mod tests {
assert!(!err.to_string().contains("secret-token"));
}
#[test]
fn outbound_http_error_names_allowlist_origin_without_path() {
let url = Url::parse("http://192.168.1.2:1880/webhook/rustfs").expect("valid endpoint");
let err = format_outbound_http_url_error(
"endpoint URL",
&url,
OutboundPolicyError::ForbiddenHost {
host: "192.168.1.2".to_string(),
reason: "private address",
},
);
assert!(err.contains(ENV_OUTBOUND_ALLOW_ORIGINS));
assert!(err.contains("http://192.168.1.2:1880"));
assert!(!err.contains("webhook/rustfs"));
assert!(err.contains("origin only, no path"));
}
// Absolute on Linux, macOS, and Windows. temp_dir needs no filesystem to exist for a
// validation-only test, and Path::is_absolute stays true across platforms.
fn nats_queue_dir() -> String {
@@ -1495,8 +1495,6 @@ async fn cancelled_transition_waiting_for_prepared_reader_cleans_remote() {
#[serial]
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
async fn delete_transitioned_object_removes_remote_tier_copy_via_usecase() {
use super::storage_api::test::ReqInfo;
let (_disk_paths, ecstore) = setup_test_env().await;
let usecase = DefaultObjectUsecase::from_global();
@@ -1533,11 +1531,6 @@ async fn delete_transitioned_object_removes_remote_tier_copy_via_usecase() {
Method::DELETE,
);
insert_header(&mut req.headers, SUFFIX_FORCE_DELETE, "true");
req.extensions.insert(ReqInfo {
cred: Some(rustfs_credentials::Credentials::default()),
is_owner: true,
..Default::default()
});
Box::pin(usecase.execute_delete_object(req))
.await
@@ -1742,8 +1735,6 @@ async fn compensation_driven_complete_multipart_upload_still_transitions() {
#[serial]
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
async fn compensation_driven_transition_still_cleans_remote_tier_on_delete() {
use super::storage_api::test::ReqInfo;
let (_disk_paths, ecstore) = setup_test_env().await;
let usecase = DefaultObjectUsecase::from_global();
@@ -1780,11 +1771,6 @@ async fn compensation_driven_transition_still_cleans_remote_tier_on_delete() {
Method::DELETE,
);
insert_header(&mut req.headers, SUFFIX_FORCE_DELETE, "true");
req.extensions.insert(ReqInfo {
cred: Some(rustfs_credentials::Credentials::default()),
is_owner: true,
..Default::default()
});
Box::pin(usecase.execute_delete_object(req))
.await
+2 -20
View File
@@ -6615,8 +6615,7 @@ impl DefaultObjectUsecase {
));
}
let is_owner = req_info_ref(&req).map(|info| info.is_owner).unwrap_or(false);
if !recursive_force_delete_is_authorized(&req.headers, is_owner, false) {
if !recursive_force_delete_is_authorized(&req.headers, req_info_ref(&req)?.is_owner, false) {
return Err(S3Error::with_message(
S3ErrorCode::AccessDenied,
"Recursive force-delete is restricted to administrative requests",
@@ -7082,7 +7081,7 @@ impl DefaultObjectUsecase {
authorize_request(&mut req, Action::S3Action(S3Action::ReplicateDeleteAction)).await?;
}
let is_owner = req_info_ref(&req).map(|info| info.is_owner).unwrap_or(false);
let is_owner = req_info_ref(&req)?.is_owner;
if !recursive_force_delete_is_authorized(&req.headers, is_owner, replica) {
return Err(S3Error::with_message(
S3ErrorCode::AccessDenied,
@@ -13387,23 +13386,6 @@ mod tests {
let err = usecase.execute_delete_objects(req).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
assert_eq!(err.message(), Some("Not init"));
}
#[tokio::test]
async fn execute_delete_object_allows_non_force_request_without_req_info_until_store_lookup() {
let input = DeleteObjectInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.build()
.unwrap();
let err = DefaultObjectUsecase::without_context()
.execute_delete_object(build_request(input, Method::DELETE))
.await
.expect_err("an uninitialized store should be reported after non-force admission");
assert_eq!(err.code(), &S3ErrorCode::InternalError);
assert_eq!(err.message(), Some("Not init"));
}
#[test]