6052 Commits

Author SHA1 Message Date
Zhengchao An 6ef7bac071 test(connect): isolate offline enrollment e2e root (#6659)
* test(connect): isolate offline enrollment e2e root

* test(connect): own e2e issuer key id
2026-08-26 18:50:58 +08:00
Zhengchao An 9ea5cd59ca fix(connect): ignore directory link-count churn (#6658) 2026-08-26 18:50:41 +08:00
唐小鸭 286626c1bd feat(kms): bulk DEK rekey sweep with admin API and kms:Rekey action (#6654) 2026-08-26 18:19:28 +08:00
Zhengchao An ff47714363 fix(tests): stabilize main CI concurrency fixtures (#6655)
* fix(tests): retry transient inventory replacement failures

* test(rio): isolate h2 keepalive fixture runtime
2026-08-26 18:16:47 +08:00
houseme 5a424219d2 perf(signer): cache signing key to avoid redundant HMAC-SHA256 (#6651)
* perf(signer): cache signing key to avoid redundant HMAC-SHA256

Cache the AWS4 signing key per (secret, region, date, service_type)
tuple. The signing key is derived from 4 HMAC-SHA256 calls and is
constant for a given user within the same UTC day, so caching it
eliminates ~0.5-1ms of redundant crypto per request.

The cache uses a LazyLock<Mutex<HashMap>> with automatic daily
rotation (cache entries naturally expire when the date component
of the key changes).

Refs: https://github.com/rustfs/backlog/issues/2005

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(signer): bound signing key cache

* fix(signer): satisfy cache lint

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-08-26 17:00:00 +08:00
Zhengchao An 2ef9519bea fix(tests): tolerate sparse resume shard fixtures (#6653) 2026-08-26 16:47:18 +08:00
hector 0c85dbd8e9 ci(nightly): build on sm-standard-4 (#6652) 2026-08-26 16:41:27 +08:00
houseme a45cf6b521 perf(runtime): mark Tokio threads as mimalloc threadpool (#6646)
Upgrade rustfs-mimalloc and rustfs-mimalloc-sys to 0.5.1, then call the new safe wrapper from Tokio worker thread startup so mimalloc can treat runtime threads as threadpool workers.

Keep the hint no-op on Windows, matching RustFS allocator platform boundaries.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-26 16:11:03 +08:00
Zhengchao An b059569744 fix(ci): refresh Linux full E2E selection (#6649) 2026-08-26 16:10:19 +08:00
唐小鸭 7ecb44ea60 feat(kms): object-level DEK rewrap adapter and Transit context-bound rewrap (#6644)
* feat(kms): object-level DEK rewrap adapter and Transit context-bound rewrap

* fix(kms): zeroize rewrap plaintext on cancellation

---------

Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-08-26 15:18:12 +08:00
Zhengchao An 08d16d067d fix(ci): suppress macOS-only dead-code warning for INVENTORY_UID (#6641)
The INVENTORY_UID constant is only referenced inside
#[cfg(target_os = "linux")] test functions, so it appears unused on
macOS. Add a cfg_attr to allow dead_code on non-linux targets.
2026-08-26 15:11:16 +08:00
hector 766d88cc89 ci(nightly): persist the nightly deb on Cloudflare R2 (#6643)
* ci(nightly): persist the nightly deb on Cloudflare R2

Upload the deb to artifacts/rustfs/packages/nightly/ (dated name plus a
rustfs-nightly-latest.deb alias) through the same R2 channel package.yml
uses, so the nightly package can be downloaded later with a stable URL.
The step is skipped when the R2 secrets are not configured, keeping the
artifact-only mode intact.

* test: add pool expansion / decommission E2E script and workflow

Add the admin-API based pool expansion, rebalance and decommission test
script (scripts/test/rustfs_pool_expand.sh) plus a workflow_dispatch /
nightly workflow that runs it on a self-hosted runner against real nodes.
The workflow accepts a release tag or a direct .deb URL (e.g. nightly/R2
package) via the package_url input.

* ci(pool-test): run the pool expansion test on the smoke-testing runner
2026-08-26 15:09:09 +08:00
Zhengchao An 3c1e172600 fix(tests): stabilize Connect inventory fixtures (#6645)
* fix(tests): create inventory fixtures securely

* fix(tests): wait for inventory retry request
2026-08-26 15:08:59 +08:00
Zhengchao An 4cd38feae7 fix(tests): restrict Connect state tempdirs (#6642) 2026-08-26 14:29:51 +08:00
唐小鸭 a4377b6351 feat(kms): bind encryption context into DEK envelopes as AAD (#6639) 2026-08-26 13:58:13 +08:00
Zhengchao An 51041e917e fix(tests): repair main CI fixtures (#6636) 2026-08-26 13:57:37 +08:00
唐小鸭 32346f159a feat(kms): wire Vault custom CA and mTLS client identity (#6638) 2026-08-26 13:32:29 +08:00
Zhengchao An 45f706b274 test(iam): pin the policy-to-iam error mapping and record the fold verdict (#6635)
Backlog#1845 step 8 conclusion. The plan called for folding iam::Error into a policy::Error #[from] wrapper and deleting the hand-written mapping. Measurement rejected the fold: the duplicated variants have ~220 construction/match sites (about 140 in production) across iam and the admin handlers - all auth-critical - and the alias route is blocked by the orphan rule (iam's From<IamStorageError> and io conversions cannot be implemented for a foreign type). Meanwhile the drift risk the fold targeted is already compiler-covered: the From match is exhaustive with no catch-all, so any new policy variant fails the build until mapped.

What remains of the step, delivered: the six dead policy variants are gone (previous commit), the grouped lossy arm is down to the two variants actually produced, a doc comment on the From impl records the verdict with the evidence, and a new totality test constructs one representative of every policy::error::Error variant and asserts the conversion preserves the rendered message - so the mapping is now pinned loss-free in both directions the classifiers care about.

Ref rustfs/backlog#1845
2026-08-26 13:30:23 +08:00
cxymds a5386093d8 test(ilm): cover restore failure expiry and retry (#6637) 2026-08-26 13:25:54 +08:00
唐小鸭 b1b3655bf8 feat(kms): surface non-production backend positioning at runtime (#6633) 2026-08-26 13:25:49 +08:00
hector b49c9a07d1 ci(nightly): build and upload a nightly deb package (#6632)
The nightly GNU build now also packages the release binary as
rustfs-nightly-<YYYY-MM-DD>.deb (Asia/Shanghai date, matching the schedule
timezone) and uploads it as a workflow artifact. Packaging mirrors
package.yml: DEBIAN control/conffiles and the systemd service from
deploy/build/, built with fakeroot dpkg-deb.
2026-08-26 13:25:43 +08:00
Henry Guo 5e05bd5485 fix(rio): prevent h2 keepalive from aborting active streams (#6630)
fix(rio): avoid false h2 keepalive stream aborts

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-08-26 13:25:38 +08:00
Zhengchao An 2b61990ec6 refactor(heal): classify recoverability typed-first with documented fallback (#6629)
refactor(heal): classify recoverability typed-first with documented needle fallback

Backlog#1845 step 6. Heal's retry decision leaned on substring matching of rendered messages; the typed information available in the error values now takes priority:

- Lock failures classify by LockError's own taxonomy instead of the blanket Lock(_) => recoverable: fatal variants (ResourceNotFound / PermissionDenied / Configuration) are terminal since retrying cannot fix them, while contention and transport variants (Timeout, Network, Internal, AlreadyLocked, QuorumNotReached, InsufficientNodes, ...) stay recoverable exactly as before.
- DiskError::RemoteClientUnavailable and its StorageError twin (typed in #6619) join the typed recoverable lists, so client-acquisition failures no longer depend on which needle happens to appear in the detail.
- task.rs is_transient_lock_or_timeout_error consults LockError::is_retryable / QuorumNotReached and the typed Timeout variants before falling back to needles.
- The substring list is demoted to a documented fallback: every needle now carries a producer census comment naming what emits it, with the shrink-only rule stated (delete the needle when its producer becomes typed end-to-end). heal rename incomplete remains the one needle with no typed producer.

heal gains a direct rustfs-lock dependency (already transitive via ecstore) to name LockError variants.

New tests pin each typed source: contention/transport lock variants recoverable, fatal lock variants terminal, RemoteClientUnavailable recoverable with a detail that avoids every needle. All existing recoverability tests stay green.

Ref rustfs/backlog#1845
2026-08-26 13:25:33 +08:00
Zhengchao An b610d5a55d fix(policy): remove six dead error variants (#6631)
Backlog#1845 step 8 prerequisite. policy::error::Error carried six variants with zero construction and zero match sites anywhere in the workspace: ErrCredMalformed, CredNotInitialized, NoAccessKey, InvalidToken, InvalidAccessKey, InvalidExpiration. Their only reference was the grouped fallthrough arm in iam's From<policy::error::Error>, whose own dead same-name twins were already removed in backlog#1831 (#6030).

Delete the variants and their display-message test rows; the iam mapping's grouped arm shrinks from eight variants to the two that are actually produced (InvalidServiceType from service_type parsing, JWTError via #[from]). This clears the way for folding the remaining 25-arm hand-written mapping (backlog#1845 step 8).

Ref rustfs/backlog#1845
2026-08-26 13:24:36 +08:00
Zhengchao An 8f0d4a20d1 refactor(ecstore): extract the embedded S3 client into rustfs-s3-client (#6627)
The storage engine embedded a ~8.4K-line hand-written S3 HTTP client under crates/ecstore/src/client (rustfs/backlog#1842). That client is a legitimate engine capability — it consumes remote S3-compatible endpoints for ILM tier warm backends and transition targets — but it was misfiled inside the engine, dragging s3s/hyper wire types into ecstore and blocking ARCHITECTURE.md invariant 4.

This PR is the pure-move step: 21 modules move verbatim to the new crates/s3-client crate (rustfs-s3-client), and crates/ecstore/src/client/mod.rs becomes a re-export shim so every in-crate crate::client:: path keeps working. The two server-side modules that were historically misfiled under client/ — object_api_utils.rs and object_handlers_common.rs — stay in ecstore.

Three reverse dependencies from the client into engine internals are severed so the move can be pure:

- transition_api::ReaderImpl::ObjectBody held ecstore's GetObjectReader; the client only ever reads the body, so the variant now holds an ObjectReader newtype over Box<dyn AsyncRead + Send + Sync + Unpin> with the same read_all() surface. The single production construction site (set_disk transition upload) and the two engine-side consumers were adjusted.
- api_list/api_remove used ecstore's storage_api_contracts / object_api types; api_list now imports BucketInfo from rustfs-storage-api directly, and api_remove uses the client's own transition_api::ObjectInfo (only .name/.version_id were read; the error-path bucket name is now threaded as a parameter instead of read from the deleted objects).
- the api_put_object_streaming regression tests built a GetObjectReader by hand; they now wrap the duplex stream in ObjectReader::new.

Guard updates: the s3s footprint ratchet gains an ecstore-scoped counter (42 files, shrink-only, per rustfs/backlog#1842), the ecstore module-lint-blanket register follows the moved files into crates/s3-client so the blanket ratchet keeps covering them, the logging guardrail path pin follows transition_api.rs, and the ::other(format!) baseline is regenerated (moved call sites left ecstore).

Verification: cargo check -p rustfs-s3-client -p rustfs-ecstore; cargo nextest run -p rustfs-s3-client (43 passed) and -p rustfs-ecstore (4515/4523; the 8 failures reproduce identically on pristine origin/main on the same machine); cargo clippy --all-targets; scripts/check_layer_dependencies.sh, check_architecture_migration_rules.sh, check_s3s_footprint.sh, check_logging_guardrails.sh, check_error_other_format_ratchet.sh, check_doc_paths.sh, check_ci_paths_sync.sh all pass.
2026-08-26 12:38:52 +08:00
Zhengchao An 65a7cc9cd4 refactor(replication): name the resync state error and keep io failures typed (#6628)
Backlog#1845 step 7. The replication crate's hand-rolled, crate-generic Error type actually describes one thing: failures of the persisted resync/MRF state files. Rename it to ResyncStateError so the name says so, and stop collapsing io::Error into Other(String): a new Io(std::io::Error) variant keeps the kind and source chain, Display renders identically, and the ecstore boundary maps it to StorageError::Io so the kind survives into store-layer classification instead of degrading into a stringified other().

No thiserror introduced - the crate keeps its zero-internal-deps posture and hand-written impls.

Ref rustfs/backlog#1845
2026-08-26 12:32:53 +08:00
Zhengchao An aa56d4b847 refactor(ecstore): make store-to-disk error narrowing named and fallible (#6626)
refactor(ecstore): make store-to-disk error narrowing a named fallible operation

Backlog#1845 step 4. The blanket impl From<StorageError> for DiskError let ? silently push store-only errors (locks, buckets, quotas) across the disk boundary into DiskError::other, where the rendered message fragments reduce_errs quorum buckets. Same story for the blanket From<StorageError> for rustfs_filemeta::Error and its other() catch-all.

Both impls are replaced by named, fallible methods: StorageError::narrow_to_disk() and StorageError::narrow_to_filemeta(). Variants with an identity on the far side map across unchanged - including the two documented lossy collapses (SlowDown -> TooManyOpenFiles, StorageFull -> DiskFull) that the round-trip tests pin - and everything else returns Err(self) so the call site decides what crossing the boundary means. Removing the impls let the compiler enumerate every conversion site; the census that scoped this issue had found 5, the compiler found 33.

Call sites keep their existing behavior: the io identity bridge and the generic sites fold Err into the io-backed other() exactly as the old catch-all did (identity still recoverable by downcast), listing paths use one shared to_filemeta_err helper, and the two sites that relied on the SlowDown collapse now construct DiskError::TooManyOpenFiles directly so the loss is visible where it happens. No behavior change intended anywhere; the io::Error bridge itself is untouched by design.

Ref rustfs/backlog#1845
2026-08-26 12:32:37 +08:00
Henry Guo 1590d9107b fix(scanner): rebuild missing usage floor after upgrade (#6624)
* fix(scanner): rebuild missing usage floor after upgrade

* fix(scanner): preserve missing-floor reset across conflicts

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-08-26 12:30:53 +08:00
YR Chen c7f201e6cb docs(ecstore): correct get_lock_acquire_timeout doc comment (#6622)
The doc comment named RUSTFS_LOCK_ACQUIRE_TIMEOUT with a 30-second
default, but the function reads RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT
with a 5-second default. RUSTFS_LOCK_ACQUIRE_TIMEOUT is a real,
separate knob read by the lock and scanner crates, so tuning the
documented name silently has no effect on this path.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 12:01:56 +08:00
Zhengchao An a1ed41e109 fix(ci): refresh Linux e2e full selection (#6625) 2026-08-26 12:01:33 +08:00
Zhengchao An 4bc9dc482a fix(ci): release Connect test locks before await (#6620) 2026-08-26 11:37:07 +08:00
Zhengchao An c0c208d89a feat(ecstore): type internode client-acquisition failures for quorum buckets (#6619)
* feat(ecstore): type internode client-acquisition failures for stable quorum buckets

Backlog#1845 step 3, first typed family. The largest other(format!) message family in ecstore was 'can not get client, err: {detail}' (~50 production sites): every internode RPC that fails to acquire a client wrapped the dial/auth error with per-peer detail into DiskError::other / StorageError::other, whose Io equality compares the rendered message. N disks failing for this same cause therefore counted as N distinct errors in reduce_errs, starving quorum aggregation, and remote_disk call sites double-wrapped the message on top of get_client's own wrap.

Introduce DiskError::RemoteClientUnavailable(String) (wire code 0x2B) and its StorageError twin (StorageErrorCode 0x54): equality and hashing use the wire code alone, so same-cause failures land in one quorum bucket regardless of per-peer detail, while Display keeps the detail so substring classifiers (network needles, heal recoverability) keep reading it unchanged. Wire encoding carries the rendered detail in error_info and decode restores the typed variant; old peers fall back to the legacy string form gracefully.

Call sites: remote_disk get_client/get_bulk_client/offline-bypass/recovery-probe now construct the typed variant and the ~60 redundant double-wrap map_errs are gone; peer_rest_client's three client getters and offline gates, peer_s3_client, and admin_server_info follow. The tier-config-reload connection classifier's anchored 'can not get client' substring check becomes a typed match on the variant (the string form is retired and now classifies as Terminal, pinned by test).

Ref rustfs/backlog#1845

* chore(ci): refresh error other ratchet baseline

* fix(ecstore): classify typed client network failures
2026-08-26 11:24:56 +08:00
Zhengchao An 7cac528de3 refactor(protos): move compat manifest send-site assertions into owning crates (#6618)
refactor(protos): move internode compat manifest send-site assertions into owning crates

Promotes the rolling-upgrade dual-write manifest from a test-only constant in rustfs-protos into the public rustfs_protos::compat_manifest module, moves the JSON-encoder send-site assertions into the crates that own the asserted sources (ecstore remote_disk.rs for requests, the rustfs binary node_service/disk.rs for responses), and splits the scanner Phase-0 overlap inventory so its heal- and ecstore-owned halves live in those crates. Adds a cross-crate include_str!/include! guard with fixture self-tests to scripts/check_layer_dependencies.sh so a library crate can never again read another crate's Rust source at compile time, and records the rule in docs/architecture/crate-boundaries.md.

Part of rustfs/backlog#1884.
2026-08-26 11:11:16 +08:00
Zhengchao An eaf0d4da81 fix(ci): keep Connect integration fixtures public-only (#6617) 2026-08-26 11:09:51 +08:00
Zhengchao An bab9049cb0 fix(ci): clear remaining merged main Clippy lints (#6616) 2026-08-26 10:50:49 +08:00
Zhengchao An df5ae13fd0 refactor(common): move scanner/heal contracts into dedicated crates (#6615)
refactor(common): move scanner/heal domain contracts into dedicated crates

crates/common carried ~5.6K lines of scanner/heal domain code (metrics.rs,
heal_channel.rs, last_minute.rs) parked there to break dependency cycles;
every scanner type change recompiled all 11 rustfs-common dependents.

Pure move, zero renames, zero shape changes (backlog#1843):

- New crate rustfs-heal-contracts receives heal_channel.
- New crate rustfs-scanner-contracts receives metrics, last_minute, and the
  GLOBAL_INIT_TIME trio (metrics::report() reads it as the current-cycle
  fallback, so it must live below the shim to avoid a dependency cycle).
- rustfs-common re-exports everything at the old paths as a transitional
  shim; consumers migrate crate by crate, then the shims are deleted.
2026-08-26 10:49:12 +08:00
Zhengchao An 37a50f1e5d test(ecstore): pin error conversion round-trips for heal-matched variants (#6613)
Backlog#1845 step 1 (pure tests, no behavior change): pin the current behavior of every conversion seam an error crosses before heal, replication, or quorum aggregation classifies it, so the later typed-variant and narrow_to_disk() refactors change these expectations deliberately rather than silently.

Covered seams: DiskError <-> StorageError, DiskError <-> node_service wire Error, DiskError/StorageError <-> io::Error (the by-design identity bridge), and StorageError <-> rustfs_filemeta::Error.

Documented lossy edges pinned as-is: SlowDown collapses to TooManyOpenFiles across the disk boundary (StorageFull to DiskFull likewise), the wire Io catch-all re-wraps the rendered message on every hop and drops the io::ErrorKind, and other(format!) messages with per-disk detail fragment reduce_errs quorum buckets while identical messages still bucket together.

Ref rustfs/backlog#1845
2026-08-26 10:32:46 +08:00
Zhengchao An 4f4d268155 ci: ratchet ecstore ::other(format!) error construction shrink-only (#6614)
Backlog#1845 step 2. reduce_errs buckets per-disk errors by equality, and Io equality compares the rendered message, so an other(format!(..)) error embedding per-disk detail makes N same-cause failures count as N distinct errors during quorum aggregation. The census that opened the issue counted 1,609 such sites; the production count in crates/ecstore/src is 657 today and was still growing.

Freeze it: scripts/check_error_other_format_ratchet.sh counts ::other(format! sites per file (trailing #[cfg(test)] modules excluded) against a shrink-only per-file baseline, failing on any growth and on stale entries after a shrink, following the layer-dependency-baseline model. Wired into make pre-commit / pre-pr / dev-check and the CI Quick Checks job.

Ref rustfs/backlog#1845
2026-08-26 10:21:25 +08:00
Zhengchao An 9db1d6f06b fix(ci): restore merged main Clippy lanes (#6612) 2026-08-26 10:19:47 +08:00
Henry Guo be22175035 fix(capacity): honor high-latency timeout profile (#6611)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-08-26 10:03:34 +08:00
Zhengchao An 45c03ca37f test(obs): move source-text logging tests into the logging guardrail script (#6610)
test(obs): replace source-text logging tests with logging guardrail script coverage

The seven fs::read_to_string source-text tests in crates/obs/src/logging.rs asserted retired logging patterns and required structured-logging fields across 13 files in other crates, four of them reverse reads into the rustfs binary crate. Their patterns are now enforced by scripts/check_logging_guardrails.sh, which runs in pre-commit and CI, covers the same files through checked_files plus require_patterns, and does not silently lapse when a governed file moves.

Part of rustfs/backlog#1884.
2026-08-26 09:55:49 +08:00
Zhengchao An 0ad6bf72cb fix(ci): restore merged main static gates (#6609) 2026-08-26 09:51:37 +08:00
Zhengchao An 5243bee746 ci: stop daily freshness false alarms for dormant scheduled workflows (#6608) 2026-08-26 09:51:22 +08:00
Zhengchao An 42d47b5f1e docs(release): require confirmation after preview validation (#6607) 2026-08-26 09:51:18 +08:00
唐小鸭 9118a6e344 feat(ecstore): closed-form range seek for single-part v2 encrypted objects (#6601)
Single-part encrypted objects in the legacy format could not serve range
reads without decrypting from byte 0: v1 frames are emitted per upstream
read, so no closed-form plaintext-to-ciphertext mapping exists. The v2
layout fixed the frame length (8218 ciphertext bytes per 8 KiB plaintext
frame), making the mapping closed-form.

Consume it:
- Single-part PUTs that encrypt locally under the v2 write switch stamp
  the frame-layout marker, valued with the object's data_dir token -
  ciphertext passthrough, data movement and copies mint a new data_dir
  or strip the marker, so a re-homed marker disqualifies itself.
- The encrypted read plan seeks marked, uncompressed single-part objects
  to frame_index * 8218 and decrypts from that frame: the frame index
  rides the plan's sequence-number slot into DecryptReader::new_at_block,
  whose nonce and AAD bind absolute indices. New metric path label
  frame_seek.
- A lying marker fails closed: v2 authentication rejects bytes at a fake
  frame boundary; plaintext is never served from the wrong offset.

Compressed objects and multipart sub-part seeks keep the conservative
paths (follow-up work); reading needs no switch - seekability follows
the marker.
2026-08-26 09:43:34 +08:00
唐小鸭 f469869620 feat(rio): authenticated fixed-frame v2 encryption layout behind a write switch (#6600)
The legacy rio v1 stream format authenticates only each frame's
ciphertext: the 8-byte header (length + plaintext CRC32) and the end
marker sit outside the AEAD, frames carry no position binding, and
nothing marks the last frame - header rewrites, frame reordering and
truncation of trailing frames are not cryptographically detected.

Add a v2 layout in the same format family, dispatched per frame by the
type byte:
- the header plus the frame's index are AEAD associated data (0x01), so
  header tampering, reordering and cross-position splicing fail
  authentication;
- the final frame carries its own authenticated type byte (0x02); a
  clean EOF or an end marker before it is an error, every stream
  (including the empty one) ends in an authenticated final frame, and a
  v2 multipart stream fails if it ends before all listed part segments;
- the writer accumulates full 8 KiB blocks, so non-final frames are
  fixed-length (8218 ciphertext bytes) and single-part objects gain a
  closed-form offset mapping for the follow-up range seek.

Key hierarchy, nonce derivation, envelopes and metadata are unchanged;
v1 objects stay readable forever, while v2 frames reject the historical
nonce fallbacks and unknown frame types become a hard error.

Write side ships off by default (RUSTFS_ENCRYPTION_FRAME_V2): mixed
version clusters cannot read v2 frames, and encrypted ciphertext travels
verbatim through transition, decommission and SSE-C replication
passthrough. This release ships read support; the default flips in a
following release.
2026-08-26 09:36:00 +08:00
唐小鸭 5834949c56 feat(kms): seal persisted config secrets with RUSTFS_KMS_CONFIG_SECRET (#6599)
The dynamic-configuration flow persisted KmsConfig to cluster storage as
raw JSON, leaving inline authentication material - the Vault token, an
AppRole secret_id, the Local master key - in config/kms_config.json in
cleartext.

Add rustfs_kms::config_secret: with the per-node RUSTFS_KMS_CONFIG_SECRET
set, those field values are sealed in place before persistence (Argon2id
with the Local key store's parameters + AES-256-GCM, per-value random
salt, the field's logical label bound as AEAD associated data so sealed
values cannot be swapped between fields). Sealed values carry the
versioned prefix RUSTFS-KMS-ENC[v1]:.

Compatibility is warn-only by owner decision: an unset secret keeps the
plaintext format and warns naming the exposed fields; plaintext values
load forever and reseal on the next save. Sealed values fail closed on a
missing or wrong secret. The sealing secret must be an independent trust
root - reusing the Local master key or Static secret is refused,
mirroring the backup-KEK rule.
2026-08-26 09:35:53 +08:00
唐小鸭 f51b06f0ae perf(ecstore): enable encrypted range part-seek by default (#6598)
Range GETs on encrypted objects read the whole ciphertext from offset 0
and discarded the decrypted prefix, because the part-boundary seek
shipped behind RUSTFS_ENCRYPTED_RANGE_SEEK defaulted to false
(backlog#1316 Phase A).

Flip the default to true. Safety rests on the marker chain: MPUs created
without a candidate layout marker never become seek-eligible,
CompleteMultipartUpload promotes the candidate to the quorum marker only
after revalidating it against the object's data_dir under the uploadId
write lock, and reads seek only when the quorum marker matches the
current data_dir. Single-part, compressed and markerless objects keep
the full-read path; RUSTFS_ENCRYPTED_RANGE_SEEK=false remains the kill
switch.

The stale default-off regression test becomes
test_legacy_range_seek_defaults_enabled: the unset-env default must
match the explicit opt-in plan, seek past the leading parts, and not
span the whole ciphertext.
2026-08-26 09:35:48 +08:00
唐小鸭 a65b306fb0 perf(sse): drop the second KMS decrypt from encrypted GET responses (#6597)
perf(sse): classify GET response headers without a second KMS unwrap

An SSE-KMS GET performed two backend Decrypt calls per request: the
object layer's encryption resolver unwraps the envelope to build the
decrypted stream, and the S3 layer then called sse_decryption again
purely to derive response headers, discarding the returned key bytes.

Replace the S3-layer call with classify_sse_read_response, which
reproduces that call's behavior from stored metadata alone: SSE-C
validation errors and precedence, per-key kms:Decrypt authorization
ahead of every other failure mode, and the request's KMS audit summary
fields. The success outcome stays honest because a failed unwrap aborts
the read in the object layer before response classification is reached.

Tests cover header parity against the unwrap-based path, audit-tag
parity for allowed and denied principals, SSE-C validation parity, and
prove classification needs no DEK provider at all.
2026-08-26 09:35:42 +08:00
Sinan Eldem b93e7b2355 feat(admin): self-service account management and TOTP two-factor authentication (#6596)
* feat(madmin): add account and two-factor wire contract

Defines the self-service account and MFA API shapes in one place so the
console and the `rc` CLI decode identical payloads instead of each
carrying its own copy of the contract.

`AccountMutability` is part of the contract on purpose: a client needs to
know whether the server will accept a password change for this identity
before offering the control, rather than discovering it from a rejected
request.

* feat(s3-types): add IAM identity audit events

Adds `iam:Identity:CredentialChanged` and `iam:Identity:AuthChallenge`
so account and authentication activity reaches the audit pipeline in its
own namespace, the way the KMS events already do. Neither is reachable
from a bucket notification config.

Two variants for the whole surface rather than one per operation:
`mask()` gives every variant its own bit in a `u64`, and the budget is
nearly spent (63 of 64 used after this). The per-operation detail lives
in `AuditEntry::api.name` and the `iamOperation` tag, which is what a
SIEM filters on anyway. Splitting these further needs `mask()` widened
first.

* feat(iam): add two-factor authentication primitives

Implements the state machine behind TOTP enrollment and verification in
the IAM domain, so the admin handlers stay HTTP plumbing and the console
and CLI drive identical logic.

* `totp`: RFC 6238 over the workspace's existing hmac/sha1, pinned to the
  published Appendix B vectors. SHA-1, 6 digits, 30s: the parameters every
  mainstream authenticator app implements. Verification returns the
  matched time step so the caller can burn it.
* `recovery`: ten single-use codes, 100 bits each, in a Crockford base32
  alphabet without I/L/O/U. Stored as domain-separated SHA-256 digests —
  a password KDF would have to run once per stored code on every attempt,
  turning each guess into an attacker-controlled cost, and with uniform
  100-bit input there is no dictionary for it to defend against.
* `challenge`: stateless HMAC tokens. A TTL cache would be node-local, so
  a cluster without session affinity would issue on one node and verify
  on another; nothing here needs replicating.
* `record`: two-phase enrollment, replay high-water mark, and lockout.
  Pending enrollment never gates a login, so a mis-scanned QR cannot lock
  an operator out, and re-configuring keeps the old factor working until
  the new one is confirmed.
* `store`: one object per identity under `config/mfa/`, a sibling of
  `config/iam/` so the IAM cache loader's startup walk does not sweep it
  up. Optimistic `If-Match` writes; deliberately uncached, because a cache
  would need cluster-wide invalidation to keep the replay mark and the
  lockout counter honest.
* `qr`: server-side rendering, so neither client needs a QR encoder.

Enrollment is refused without `RUSTFS_IAM_MASTER_KEY`. A TOTP secret is
credential-equivalent, and one written in plaintext could be lifted off a
disk — worse than no second factor, because the user believes they have
one. IAM identities tolerate a missing master key for backward
compatibility; a new feature has no such history to honour.

Also adds `IamSys::revoke_sts_sessions_for_parent`, so a credential
rotation can invalidate the sessions minted under the old secret.

* feat(admin): add self-service account endpoints and the two-factor login gate

Adds the account surface (`/v3/account/*`), the second-factor endpoints,
the administrative reset (`/v3/user/mfa`), and `PUT
/v3/set-user-secret-key`, plus the gate on `AssumeRole`.

What the gate covers, and what it deliberately does not:

* `AssumeRole` is the only interactive login RustFS has, so it is where a
  second factor can be enforced. With one enrolled it requires
  `TokenCode`; without an enrollment the code path is unchanged, so
  existing deployments are untouched.
* A request signed directly with a long-term access key stays ungated.
  Gating it would break every script and CLI the moment a human enabled
  2FA on their own account, and would add no protection: whoever holds
  the secret key already has full access without presenting a code. This
  is the division AWS draws; making 2FA meaningful for API access needs an
  `aws:MultiFactorAuthPresent` policy condition, tracked separately.

`SerialNumber`/`TokenCode` are STS's own parameters, so an SDK or script
authenticates the same way the console does.

`caller_identity` resolves who a request acts as. The console signs with
a short-lived STS session, so "the caller" is almost never the key that
signed. It reports two separate capabilities: root cannot rotate its
secret (a process-wide `OnceLock` that also derives the internode RPC
secret) but *can* enroll a second factor — conflating the two would leave
the default deployment's console login unprotectable.

The self-service routes carry no admin action. Giving them one would be
wrong in both directions: it would stop an ordinary user from changing
their own password, and let any holder of that action change someone
else's. They gate on possession of the credential plus, for the
mutations, knowledge of the current secret — a signature only proves a
credential was used, so without that a hijacked tab could rewrite the
account's credentials or strip its second factor.

`set-user-secret-key` exists because the only prior way to change a
password was to re-POST the whole user through `add-user`, which rewrote
`status` and dropped the policy field — a password reset that silently
re-enabled a disabled account.

Wrong, replayed and malformed codes are indistinguishable on the wire;
the distinction survives only in the audit trail, where no submitted
value, secret or code is ever recorded.

* test(e2e): cover the two-factor lifecycle and its regressions

Unit tests cover the state machine at its edges; only an end-to-end test
proves the pieces are wired together and that the existing
authentication paths still behave.

Asserts, against a real server: enrollment is refused without a master
key; the full enroll/activate flow works with a genuine RFC 6238 code;
`AssumeRole` refuses without a factor and accepts a valid one; a recovery
code works exactly once; a direct SigV4 admin request keeps working with
a factor enrolled; `AssumeRole` for an unenrolled identity is unchanged;
and a password rotation invalidates the old secret.

The test computes TOTP codes itself rather than calling the server's
implementation — a shared helper could agree with a bug on both sides.

This suite caught a real defect during development: enrollment was
refused for root because its *password* is immutable, which would have
left the default deployment — an administrator signing into the console
as root — unable to protect the one login the feature exists for.

* docs(operations): document the two-factor authentication model

Records what the second factor protects and what it deliberately does
not, because several of the boundaries look like gaps until the
alternative is spelled out: why direct SigV4 access stays ungated, why
root credentials cannot be rotated at runtime, why secret keys cannot be
hashed in an S3 server, and why at-rest protection is mandatory for a
TOTP secret but optional for an IAM identity.

Also states the limitations plainly, including that GHSA-m77q-r63m-pj89
is unaffected: a holder of the root secret can still forge a session
token, 2FA claim included.

Placed alongside the other authentication and KMS security documents
rather than under a new `docs/security/`, which `.gitignore` excludes.

* fix(admin): route the new account handlers through the admin s3 facade

Two of the guardrails in the CI "Quick Checks" job rejected the previous
commits, so the required check would have gone red as soon as a maintainer
approved the workflow run.

`check_architecture_migration_rules.sh` requires everything under
`rustfs/src/admin` to reach `ECStore` through a domain module rather than
the root of `storage_api`. The MFA handler and the two `AssumeRole`
signatures now use `storage_api::runtime::ECStore`, which is where the
other ten admin handlers already take it from.

`check_s3s_footprint.sh` ratchets two counters that new code may not grow:
files referencing `s3s` and error-macro invocation lines. This branch added
four files and thirty-two lines to them. The ratchet is lower-only and its
header forbids raising a baseline to get green, so the construction moves
behind the facade instead: `storage_api::s3` now re-exports the request and
body types these handlers need and gains an `error` constructor over
`S3Error::with_message`. That is the same constructor the macro expands to
and the one `handlers/mod.rs`, `rebalance_internal_error` and
`invalid_object_lock_configuration` already call, so this is the existing
practice rather than a new one, and it keeps the `s3s` dependency in the
boundary file the s3gate migration replaces.

Every error code and message is carried over unchanged. In `sts.rs` only
the call site this branch added is converted; the sixteen that predate it
are left alone, because rewriting them would put unrelated churn in a
feature PR and push the counter below the baseline it is meant to hold.
2026-08-26 09:35:29 +08:00