Compare commits

...

2 Commits

Author SHA1 Message Date
唐小鸭 75882732dc fix(site-replication): admit only verifiable peer-edit fences 2026-08-15 08:12:03 +08:00
Zhengchao An 71e83aeec4 fix(ci): pin Docker images to release source (#6121) 2026-08-15 07:13:37 +08:00
3 changed files with 194 additions and 1 deletions
+25 -1
View File
@@ -94,6 +94,7 @@ jobs:
short_sha: ${{ steps.check.outputs.short_sha }}
is_prerelease: ${{ steps.check.outputs.is_prerelease }}
create_latest: ${{ steps.check.outputs.create_latest }}
source_ref: ${{ steps.check.outputs.source_ref }}
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
@@ -118,6 +119,7 @@ jobs:
short_sha=""
is_prerelease=false
create_latest=false
source_ref="$GITHUB_SHA"
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
# Triggered by build workflow completion
@@ -137,6 +139,7 @@ jobs:
# Extract version info from commit message or use commit SHA
# Use Git to generate consistent short SHA (ensures uniqueness like build.yml)
short_sha=$(git rev-parse --short "$HEAD_SHA")
source_ref="$HEAD_SHA"
# Determine build type based on triggering workflow event and ref
triggering_event="$TRIGGERING_EVENT"
@@ -261,6 +264,23 @@ jobs:
echo "⚠️ Only release versions (latest, v1.0.0, 1.0.0) and prereleases (v1.0.0-alpha1, 1.0.0-beta2) are supported"
;;
esac
if [[ "$should_build" == true && "$input_version" != "latest" ]]; then
tag_ref="refs/tags/$input_version"
if ! git ls-remote --exit-code origin "$tag_ref" >/dev/null 2>&1; then
if [[ "$input_version" == v* ]]; then
tag_ref="refs/tags/${input_version#v}"
else
tag_ref="refs/tags/v$input_version"
fi
fi
if ! git ls-remote --exit-code origin "$tag_ref" >/dev/null 2>&1; then
echo "❌ Release tag not found for Docker build: $input_version"
exit 1
fi
source_ref="$tag_ref"
fi
fi
{
@@ -271,6 +291,7 @@ jobs:
echo "short_sha=$short_sha"
echo "is_prerelease=$is_prerelease"
echo "create_latest=$create_latest"
echo "source_ref=$source_ref"
} >> "$GITHUB_OUTPUT"
echo "🐳 Docker Build Summary:"
@@ -281,6 +302,7 @@ jobs:
echo " - Short SHA: $short_sha"
echo " - Is prerelease: $is_prerelease"
echo " - Create latest: $create_latest"
echo " - Source ref: $source_ref"
# Build multi-arch Docker images
# Strategy: Build images using pre-built binaries from dl.rustfs.com
@@ -308,6 +330,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
ref: ${{ needs.build-check.outputs.source_ref }}
- name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
@@ -397,7 +420,8 @@ jobs:
LABELS="org.opencontainers.image.title=RustFS"
LABELS="$LABELS,org.opencontainers.image.description=RustFS distributed object storage system"
LABELS="$LABELS,org.opencontainers.image.version=$VERSION"
LABELS="$LABELS,org.opencontainers.image.revision=${{ github.sha }}"
SOURCE_REVISION="$(git rev-parse HEAD)"
LABELS="$LABELS,org.opencontainers.image.revision=$SOURCE_REVISION"
LABELS="$LABELS,org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}"
LABELS="$LABELS,org.opencontainers.image.created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
LABELS="$LABELS,org.opencontainers.image.build-type=$BUILD_TYPE"
@@ -6014,6 +6014,63 @@ fn peer_edit_fence(queries: &HashMap<String, String>) -> Option<(String, u64)> {
Some((origin.clone(), generation))
}
/// The largest generation an incoming fence may carry: this site's clock in
/// unix nanoseconds plus a day of cross-site skew. Every site authenticates
/// peer traffic with the shared site-replicator service account, so the
/// receiver cannot tell WHICH site stamped a fence — a compromised peer can
/// claim any origin, and a u64::MAX-scale generation would raise that
/// origin's high-water mark past anything the genuine site ever allocates,
/// silently fencing out its every future edit. A genuine generation is the
/// hybrid clock of [`next_peer_edit_generation`], floored by the sender's
/// wall time, so it exceeds this site's clock only by cross-site skew — and
/// the mark a ceiling-level forgery can still plant decays as real time
/// passes it, within the allowance. Only an origin still allocating with the
/// pre-hybrid plain counter cannot outrun such a mark until it upgrades: its
/// genuine generations never approach nanosecond scale.
fn peer_edit_fence_generation_ceiling(now_unix_nanos: u64) -> u64 {
const CROSS_SITE_SKEW_ALLOWANCE_NANOS: u64 = 24 * 60 * 60 * 1_000_000_000;
now_unix_nanos.saturating_add(CROSS_SITE_SKEW_ALLOWANCE_NANOS)
}
/// Whether an incoming fence may be honoured, as far as this site can vouch
/// for it. The sender's identity is unverifiable (shared service account),
/// so the check runs over what the receiving state knows: the claimed origin
/// must be a site this state currently replicates with — the same membership
/// rule the load-time mark pruning applies, so every mark recorded behind
/// this check is one a reload would keep — and not this site itself, which
/// never delivers edits to itself; and the generation must sit under
/// [`peer_edit_fence_generation_ceiling`]. The caller IGNORES an
/// inadmissible fence rather than failing the request: the delivery applies
/// exactly as an unstamped (pre-fence) delivery would, no high-water mark is
/// read or written, and the worst a forged fence achieves is forfeiting an
/// ordering guarantee its sender was never owed.
fn peer_edit_fence_is_admissible(
state: &SiteReplicationState,
local_deployment_id: &str,
fence: &(String, u64),
generation_ceiling: u64,
) -> bool {
let (origin, generation) = fence;
let result = if origin == local_deployment_id || !state.peers.contains_key(origin) {
"fence_origin_not_a_remote_peer"
} else if *generation > generation_ceiling {
"fence_generation_beyond_ceiling"
} else {
return true;
};
warn!(
event = EVENT_ADMIN_SITE_REPLICATION_STATE,
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
result,
origin = %origin,
generation = *generation,
generation_ceiling,
"ignoring inadmissible peer-edit fence"
);
false
}
/// True when a strictly newer edit from the same origin site already landed
/// here. No lock on the sending side can order deliveries issued by two
/// nodes of that site, so ordering is decided here, on the generation the
@@ -9559,6 +9616,7 @@ impl Operation for SRPeerEditHandler {
let ilm_expiry_override = sr_edit_ilm_expiry_override(&req.uri);
let endpoint_refresh_requested = queries.get("refresh-targets").is_some_and(|value| value == "true");
let commit_fence = peer_edit_fence(&queries);
let fence_generation_ceiling = peer_edit_fence_generation_ceiling(edit_generation_wall_clock());
let local_endpoint = site_replication_local_endpoint(&req.uri, &req.headers);
let (refresh_id, incoming) = if endpoint_refresh_requested {
let refresh: EndpointRefreshRequest = read_site_replication_json(req, "", false).await?;
@@ -9576,6 +9634,11 @@ impl Operation for SRPeerEditHandler {
let outcome = update_site_replication_state_when_changed(move |state| {
let mut incoming = incoming;
let local_peer = local_peer_at_endpoint(commit_endpoint, state);
// The fence is self-reported — the shared service account means
// the sender cannot be identified — so it is honoured only after
// the admissibility check, against the same state it will gate.
let commit_fence = commit_fence
.filter(|fence| peer_edit_fence_is_admissible(state, &local_peer.deployment_id, fence, fence_generation_ceiling));
// Ordering fence: the sending site allocates the generation under
// its state-object lock, so a delivery that lost the race carries
// a generation this site has already passed. Applying it would
@@ -11957,6 +12020,17 @@ mod tests {
handler_block.contains("record_applied_peer_edit_generation(state, origin, *generation);"),
"SRPeerEditHandler must record the applied generation so later stale deliveries are recognised"
);
// Fence hardening: origin and generation are self-reported by a
// caller the shared service account cannot identify, so the handler
// must pass the fence through the admissibility check — against the
// same state the fence gates, i.e. inside the transaction — before
// reading or raising any high-water mark.
assert!(
handler_block.contains(
".filter(|fence| peer_edit_fence_is_admissible(state, &local_peer.deployment_id, fence, fence_generation_ceiling))"
),
"SRPeerEditHandler must admit a fence only through peer_edit_fence_is_admissible inside the state transaction"
);
// P1-15 PR2: both halves of the fence and the edit they fence share
// ONE transaction. Checking the fence against a state read outside the
// lock would let the check pass on one snapshot and the write land on
@@ -13251,6 +13325,94 @@ mod tests {
assert!(peer_edit_delivery_is_stale(&state, origin, generation - 1));
}
/// A fence is self-reported: every site authenticates peer traffic with
/// the same site-replicator credential, so a compromised peer can stamp
/// ANY origin with ANY generation. The receiver must refuse to let such
/// a stamp touch the high-water marks — an origin it does not replicate
/// with, its own deployment id, and a generation no genuine counter
/// could have reached are all ignored, and ignoring one plants no mark.
#[test]
fn forged_peer_edit_fences_cannot_poison_the_high_water_marks() {
let mut state = SiteReplicationState {
peers: BTreeMap::from([
(
"site-local".to_string(),
PeerInfo {
deployment_id: "site-local".to_string(),
..peer("local", "https://local.example:9000")
},
),
(
"site-victim".to_string(),
PeerInfo {
deployment_id: "site-victim".to_string(),
..peer("victim", "https://victim.example:9000")
},
),
]),
..Default::default()
};
let ceiling = peer_edit_fence_generation_ceiling(edit_generation_wall_clock());
// An origin outside the current membership is refused outright...
let unknown = ("site-unknown".to_string(), 4u64);
assert!(!peer_edit_fence_is_admissible(&state, "site-local", &unknown, ceiling));
// No site delivers edits to itself: a fence claiming the receiver as
// its origin is forged by construction, current peer or not.
let own = ("site-local".to_string(), 4u64);
assert!(!peer_edit_fence_is_admissible(&state, "site-local", &own, ceiling));
// A generation past the ceiling (the u64::MAX poisoning) is refused
// even when it names a current peer.
let poisoned = ("site-victim".to_string(), u64::MAX);
assert!(!peer_edit_fence_is_admissible(&state, "site-local", &poisoned, ceiling));
let barely_over = ("site-victim".to_string(), ceiling + 1);
assert!(!peer_edit_fence_is_admissible(&state, "site-local", &barely_over, ceiling));
// With no mark planted, the victim's genuine deliveries keep
// applying, and its admitted fence works end to end.
let genuine = ("site-victim".to_string(), 1u64);
assert!(peer_edit_fence_is_admissible(&state, "site-local", &genuine, ceiling));
assert!(!peer_edit_delivery_is_stale(&state, &genuine.0, genuine.1));
record_applied_peer_edit_generation(&mut state, &genuine.0, genuine.1);
assert_eq!(state.applied_edit_generations.get("site-victim"), Some(&1));
}
/// The generation ceiling must admit both live generation shapes: the
/// small plain counter pre-hybrid sites still allocate, and the
/// wall-clock-floored hybrid of `next_peer_edit_generation` — including
/// one running AHEAD of the receiver's clock by cross-site skew, which
/// is the whole reason the allowance exists, and the ceiling itself,
/// the last admissible value.
#[test]
fn peer_edit_fence_ceiling_admits_counter_and_clock_seeded_generations() {
let state = SiteReplicationState {
peers: BTreeMap::from([(
"site-origin".to_string(),
PeerInfo {
deployment_id: "site-origin".to_string(),
..peer("origin", "https://origin.example:9000")
},
)]),
..Default::default()
};
let now = edit_generation_wall_clock();
let ceiling = peer_edit_fence_generation_ceiling(now);
let counter = ("site-origin".to_string(), 42u64);
assert!(peer_edit_fence_is_admissible(&state, "site-local", &counter, ceiling));
// One hour ahead of the receiver's clock: within the skew allowance.
// Shrinking the allowance to zero must turn this fence away.
let clock_ahead = ("site-origin".to_string(), now + 60 * 60 * 1_000_000_000);
assert!(peer_edit_fence_is_admissible(&state, "site-local", &clock_ahead, ceiling));
// The boundary is inclusive: rejection starts strictly past it.
let at_ceiling = ("site-origin".to_string(), ceiling);
assert!(peer_edit_fence_is_admissible(&state, "site-local", &at_ceiling, ceiling));
}
/// P1-15 review follow-up: a site that leaves the mesh drops below two
/// peers, which clears its state object and restarts its generation
/// counter at zero. A mark left over from its previous membership would
@@ -195,6 +195,13 @@ IFS= read -r -d '' expected_docker_automatic_guard <<'EOF' || true
EOF
expected_docker_automatic_guard=${expected_docker_automatic_guard%$'\n'}
require_job_if "$docker_workflow" "build-check" "$expected_docker_automatic_guard"
require_line "$docker_workflow" ' source_ref: ${{ steps.check.outputs.source_ref }}' "Docker source ref output"
require_line "$docker_workflow" ' source_ref="$HEAD_SHA"' "automatic Docker source ref"
require_line "$docker_workflow" ' source_ref="$tag_ref"' "manual Docker source ref"
require_line "$docker_workflow" ' ref: ${{ needs.build-check.outputs.source_ref }}' "Docker release source checkout"
require_line "$docker_workflow" ' SOURCE_REVISION="$(git rev-parse HEAD)"' "Docker source revision resolution"
require_line "$docker_workflow" ' LABELS="$LABELS,org.opencontainers.image.revision=$SOURCE_REVISION"' "Docker revision label"
require_absent "$docker_workflow" 'org.opencontainers.image.revision=${{ github.sha }}' "Docker revision must not use the workflow branch SHA"
docker_manual_guard=$(awk '
$0 == " *-preview*)" { in_preview = 1 }