Skip to content

fix(distributed): harden cluster pre-auth admission and rejections - #2705

Merged
inureyes merged 4 commits into
mainfrom
fix/issue-2692-cluster-admission-hardening
Oct 28, 2026
Merged

inureyes merged 4 commits into
mainfrom
fix/issue-2692-cluster-admission-hardening

Conversation

@inureyes

@inureyes inureyes commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Summary

Five hardening items from the #2674 security review on the authenticated cluster transport (src/distributed/) and the disaggregated router demux, in one PR with one verification pass. None changes what a party without the secret can read or inject.

  1. Per-source pending limit keyed per IPv6 /64. pending_source_key(ip) canonicalizes, keeps IPv4 as is and masks IPv6 to its /64; try_acquire and pending_from use it, PendingSlotRefusal::PerIp carries the key and prints .../64 for IPv6. Module doc, the max_pending_handshakes_per_ip field doc and docs/distributed.md say that nodes inside one /64 share the per-source budget.
  2. active_conns deleted with its three manual decrements and the AtomicUsize import; no replacement counter. cluster_auth::panicking_rpc_handler_releases_its_connection_slot is the regression guard (see the note below on its observable).
  3. src/distributed/handshake_warn.rs (HandshakeWarnClass {Noise, Diagnostic}, handshake_warn_class(&anyhow::Error), WarnBudget::allow_at(class, now) -> WarnDecision { warn, suppressed_in_previous_window }, process-wide handshake_warn_allowed(class) with the rollover summary N cluster handshake warnings (<class>) were logged at debug in the last 60s). Pending refusals draw Noise, handshake failures handshake_warn_class(&err), rejected frames Diagnostic; tcp_transport.rs and stage_assignment.rs call sites updated, the old limiter removed. Residual risk noted in docs/distributed.md.
  4. Router sender check. TcpTransport::is_authenticated(); RouterState::build fails with the disaggregated router requires a role transport bound with the cluster secret; result_senders: HashSet<SocketAddr> (prefill ∪ decode peers, canonical IPs) and accepts_result_from(&str); the demux drops other senders with the router: dropping result frame from ... warn through the Diagnostic budget. Both router test fixtures bind with auth. docs/distributed.md, docs/CONTINUOUS_BATCHING.md and the --prefill-peers / --decode-peers help now say a peer must be listed under the address it connects from (its --serving-bind, with an unspecified IP replaced by the source IP). docs/distributed.md states that the check authenticates the source IP (handshake-verified) while the port is the sender's advertised listener port, so it tells members apart by source IP plus claimed port, not by port-level proof. The demux logs drops through tracing fields without building a message string.
  5. Admission inside the handshake. VERDICT_AT_CAPACITY = 0x02, PROTOCOL_VERSION 3 to 4 (cluster_handshake.rs, constant unchanged in name and place), pub(crate) async fn server_handshake_admitting<P>(stream, auth, local_identity, suite, channel, admit: impl FnOnce() -> Option<P>) -> Result<(String, SessionKeys, P)> calling admit() only after the client authenticator verified; server_handshake / server_handshake_on wrap it with || Some(()). New errors ConnectionLimitReached { peer } (server) and ServerAtCapacity (client; not treated as fatal like HandshakeRejected: connect_with_retry returns it without backoff, the next pool acquire reconnects, and Transport::connect warns), the latter re-exported from distributed/mod.rs. accept_loop passes || conn_semaphore.try_acquire_owned().ok() and keeps the max connections (N) reached warn. Wire diagram and docs updated.

Pre-existing limitation found while writing the item 2 test: TcpTransport reads RpcResponse frames only on accepted connections, never on the pooled outbound connection an rpc_call went out on, so no RPC echo can reach a TCP client (the only in-repo RPC-over-TCP caller, bench.rs, ignores the result). The guard therefore keeps the panicking RPC handler as the trigger (client A's rpc_call(b"boom") fails) and uses client B's send reaching server.recv() within 2 s as the observable.

Changes during review

The implementation review and the security review found no CRITICAL or HIGH issues. The follow-up commit tightens wording only: the residual-risk notes now name IdentityNotOwned and FrameSuiteMismatch (decided from the hello alone) next to ClientAbortedAfterServerProof as refusals a party without the secret can push into the Diagnostic window; the ServerAtCapacity docs and the connection_pool.rs "failed handshake is terminal" comment describe the no-backoff reconnect precisely; the startup.rs router comment credits RouterState::build for enforcing cluster_auth; and the sender-check docs say only the source IP is verified.

Failing-first evidence (main 84e20ce)

Checked by temporarily reverting the fix hunks on this branch (key on the full address, one shared warn window, no is_authenticated check, no sender check), rebuilding the lib tests and running the tests by name, then restoring the files byte for byte (sha256 verified):

  • ipv6_sources_in_one_64_share_the_per_source_limit: FAILED (third acquire from 2001:db8:1:2:ffff::3 succeeded, unwrap_err panicked at pending_handshakes_tests.rs:83).
  • ipv6_sources_in_different_64s_are_counted_separately: FAILED (second address in the /64 acquired a slot, pending_handshakes_tests.rs:110).
  • noise_flood_does_not_suppress_diagnostic_warnings: FAILED (left: WarnDecision { warn: false, .. }).
  • build_refuses_an_unauthenticated_transport: FAILED (an unauthenticated transport must be refused).
  • result_demux_drops_frames_from_unconfigured_senders: FAILED (unconfigured sender's frame was delivered: ... tokens: ["injected"]).
  • rollover_reports_suppressed_count passes with a shared window too (only one class is exercised); classifies_handshake_errors and accepts_result_from_only_configured_peers call functions that do not exist on main, so they do not compile there.
  • server_at_capacity_sends_capacity_verdict, admission_is_not_consulted_before_the_client_authenticates and cluster_auth::full_connection_cap_reports_capacity_to_the_client do not compile on main (server_handshake_admitting, ServerAtCapacity, ConnectionLimitReached are new).
  • cluster_auth::panicking_rpc_handler_releases_its_connection_slot passes with and without the counter, by design.

Verification (GB10 spark-102, --profile test-fast, builds outside gpu-lock, runs under gpu-lock run --tag issue-2692)

  • cargo fmt --check: clean.
  • cargo clippy --profile test-fast --features cuda -p mlxcel --lib --tests -- -D warnings: clean (rerun after the review commit: clean; server::router_front:: + distributed::handshake_warn:: 38 passed, and the touched modules cluster_handshake, pending_handshakes, tcp_transport 53 passed at 699d208).
  • 13 new or touched tests by name (--lib -- --test-threads=1): 13 passed.
  • --lib distributed:: -- --test-threads=1: 1473 passed, 0 failed, 4 ignored.
  • --lib server::router_front -- --test-threads=1: 35 passed.
  • --test zero_config_cluster_init: 4 passed, 3 ignored.
  • tests/disaggregated_serving_e2e.rs (--features cuda,test-utils -- --include-ignored --test-threads=1): 2 passed.
  • tests/disaggregated_router_e2e.rs (same flags): 7 passed (49.7 s).
  • MLXCEL_REQUIRE_MODELS=1 MLXCEL_SDPA_DETERMINISTIC=1, --features cuda,test-utils -- --ignored --test-threads=1: pipeline_remote_real_models 10 passed, 1 failed; pipeline_server_remote_real_models 7 passed, 1 failed; pipeline_ci_multi_stage_real_models 2 passed. The two failures are the glm_moe_dsa tests panicking in repo_model_dir because glm5-4bit is absent: mlx-community/GLM-5-4bit is 418.6 GB and cannot be loaded on this 128 GB box, and the tests predate fix(distributed): cap pre-auth slots, type rejections, auth DI roles #2674 (added 2026-04-13), so this is a missing checkpoint, not a regression. Every llama, gemma3, gemma4, qwen3, qwen3.5 and glm-4.7 parity test passed.
  • Manual three-process loopback (router + prefill + decode, one shared --cluster-secret-file mode 0600, qwen3-0.6b-4bit, 127.0.0.1 ports 19801-19806, target/test-fast/mlxcel-server built from this branch): POST /v1/chat/completions returned HTTP 200 with "2 + 2 = 4.", and the router log contains no dropping result frame line. Processes killed by PID afterwards.

Closes #2692

Five follow-ups from the #2674 security review on the authenticated cluster transport. None changes what a party without the secret can read or inject; they close DoS and robustness gaps on the accept path and the router demux.

- Pending-handshake per-source limit keys IPv6 sources per /64 (`pending_source_key`), so a host that owns a /64 cannot take one pending slot per address; nodes inside one /64 share the budget (documented).
- The unread `active_conns` counter and its manual decrements are gone; every per-connection resource is RAII, guarded by `panicking_rpc_handler_releases_its_connection_slot`.
- Handshake warnings move to `distributed::handshake_warn` with separate `Noise` and `Diagnostic` budgets (5 per 60 s each) and a rollover summary, so junk connections cannot push the secret-mismatch line down to debug.
- The disaggregated router refuses an unauthenticated transport at build time and its demux delivers result frames only from configured `--prefill-peers` / `--decode-peers` identities; other holders of the secret can no longer inject tokens through the sequential request ids.
- Admission runs inside the handshake after the client authenticated: `server_handshake_admitting` answers a full cap with the new 0x02 verdict, the client gets the retryable `ServerAtCapacity`, and `PROTOCOL_VERSION` goes 3 to 4 (no released version carries 3).

Verified on GB10: fmt, clippy, the distributed:: and server::router_front lib modules, zero_config_cluster_init, the real-model remote pipeline suites, both disaggregated e2e suites, and a manual router + prefill + decode loopback (HTTP 200, no dropped result frame).

Closes #2692
@inureyes inureyes added status:review Under review type:security Security vulnerability or fix priority:medium Medium priority labels Oct 28, 2026
@inureyes

Copy link
Copy Markdown
Member Author

Implementation Review Summary

Intent

Implement the five #2692 hardening items on the authenticated cluster transport and router demux (IPv6 /64 pending key, drop active_conns, per-class handshake warn budget, router sender check, at-capacity verdict with PROTOCOL_VERSION 4).

Findings Addressed

None needed fixing at CRITICAL or HIGH. Checked specifically: admit() runs only after the client authenticator verifies; a permit taken by admit() is dropped on a later write failure or timeout (RAII, no leak); ServerAtCapacity is not HandshakeRejected, so Transport::connect warns instead of failing; the router sender check canonicalizes IP and port on both sides exactly as bind_advertised_identity produces from. The item 2 deviation is correct: RpcResponse frames are parsed only in handle_connection (accepted connections), and the pooled outbound stream an rpc_call writes on is never read, so no TCP client can receive an echo.

Remaining Items

  • handshake_warn.rs module doc defines Noise as "anything a stranger can produce without the secret", and the residual-risk notes name only ClientAbortedAfterServerProof; IdentityNotOwned and FrameSuiteMismatch are also pre-auth refusals a stranger can trigger, so they can saturate the Diagnostic window too (MEDIUM, doc accuracy only; the classification itself follows the issue)
  • ServerAtCapacity docs say the connection pool treats it as retryable; connect_with_retry returns it at once without backoff (the next acquire reconnects), and its "a failed handshake is terminal" comment now also covers this transient case (LOW, wording)
  • docs/distributed.md per-frame paragraph re-wrap leaves a short "connection, so a frame" line; the startup.rs router comment still credits only the resolver for cluster_auth being set (LOW, cosmetic)

Verification

  • All stated requirements implemented
  • No placeholder/mock code remaining
  • Integrated into project code flow
  • Project conventions followed
  • Existing modules reused where applicable
  • No unintended structural changes
  • Tests pass: cargo fmt --check and clippy --lib --tests -D warnings clean; under gpu-lock distributed::cluster_handshake 20, handshake_warn 3, pending_handshakes 6, tcp_transport 27, server::router_front 35, all passed

@inureyes

Copy link
Copy Markdown
Member Author

Security and performance review of 699d208: no CRITICAL or HIGH findings, so no fixes were pushed. Points already raised in the implementation review are not repeated.

Checked and sound:

  • Capacity state is not exposed before authentication. admit() runs only after the client authenticator verifies (a mismatch returns VERDICT_REJECT first), so a party without the secret cannot see or trigger VERDICT_AT_CAPACITY. The verdict byte is unauthenticated as before, so an on-path attacker can still swap 0x01/0x02, but it could already drop the connection.
  • No permit or slot leaks. The permit is a local that is dropped if the VERDICT_ACCEPT write fails or the handshake times out, the pending slot is dropped right after the handshake, and nothing else is counted by hand.
  • Router sender check: canonical_socket_addr rebuilds the address from ip().to_canonical() and the port. That removes IPv4-mapped forms, scope ids and flowinfo on both sides. The bound identity is never unspecified, so a configured 0.0.0.0 entry matches nothing, and an unparseable from is refused.
  • The /64 key masks the upper 64 bits of the big-endian u128, and the shift cannot overflow. The per-source map is still capped by the global pending pool, and the warn budget is a fixed two-slot array with no per-source state.
  • No lock is held across an await on the accept path. Both new mutexes are std and only cover synchronous updates.

LOW, not fixed:

  • The router check matches IP and port, but only the IP is authenticated: the port in the bound identity is whatever the sender advertised. A secret holder on the same host as a configured peer, or behind the same NAT IP, can claim that peer's port and pass the check. That is acceptable under the stated threat model (secret holders are members, and per-request binding was rejected), but docs/distributed.md says "canonical IP and port" and could add that the check tells members apart by source IP only.
  • spawn_result_demux builds the drop message with format! even when the budget sends it to debug. This costs one allocation per dropped frame, and only a secret holder can trigger it. Negligible.

Tests run (test-fast, cuda, --test-threads=1, under gpu-lock): distributed::cluster_handshake:: 20/20, distributed::handshake_warn:: 3/3, distributed::pending_handshakes:: 6/6, distributed::tcp_transport:: 27/27, server::router_front:: 35/35.

Correct the residual-risk text: IdentityNotOwned and FrameSuiteMismatch are decided from the hello before the client authenticates, so a party without the secret can trigger them and saturate the Diagnostic window, not only ClientAbortedAfterServerProof; the Noise class definition no longer claims to be the only stranger-producible class.

Describe ServerAtCapacity precisely: it is not fatal like HandshakeRejected, connect_with_retry returns it without backoff, the next acquire reconnects, and Transport::connect warns. The "failed handshake is terminal" comment in connect_with_retry now covers this case.

State that the router sender check authenticates the source IP only; the port is the advertised listener port and is not verified. Note in startup.rs that RouterState::build also enforces the cluster secret. Merge a stray re-wrapped line in docs/distributed.md. spawn_result_demux now logs the drop through tracing fields in both branches instead of building a String with format!.

No behavior change. Validation: cargo fmt --check, cargo clippy --profile test-fast --features cuda -p mlxcel --lib --tests -- -D warnings, and the server::router_front:: and distributed::handshake_warn:: lib tests (38 passed).

Refs #2692
@inureyes inureyes added status:done Completed and removed status:review Under review labels Oct 28, 2026
@inureyes
inureyes marked this pull request as ready for review October 28, 2026 06:04
@inureyes
inureyes merged commit 433560c into main Oct 28, 2026
32 checks passed
@inureyes
inureyes deleted the fix/issue-2692-cluster-admission-hardening branch October 28, 2026 06:04
inureyes added a commit that referenced this pull request Oct 28, 2026
… (#2708)

Closes #2694. The seven residuals of #2671, #2680 and #2684 in one PR, each with its own test. Reconciled against current main: the handshake `PROTOCOL_VERSION` stays 4 (#2705), the `FrameRejected` call sites keep their `handshake_warn` budget unchanged, and `ProtectedStream::peer_closed` (#2700) is untouched.

## Per item

1. `read_frame` interprets the kind byte after `FrameOpener::open` on a protected stream: a tampered invalid kind is `FrameRejected` through the existing budgeted call sites (no call-site change); an authenticated unknown kind is `unknown message kind {n} from an authenticated peer`. Plain streams and the header-only oversize check are unchanged.
2. `encode_message_frame` reserves the header and `MAX_TAG_LEN` (32, const-asserted against `FrameSuite::ALL`); `finish_frame` is the one sealing path, used by `write_prepared_frame` (`TcpTransport::send`) and by `write_frame(kind, &[u8])`. A buffer shorter than the header is an error, not a panic, and `write_prepared_frame` is `pub(crate)`. `serialize_message` is test-only.
3. `ActivationMessage::reply_nonce` (`serde(default)`) carries the `RunEntry` nonce from the entry stage through middle stages; the final stage sends `RunResult` to the coordinator's authenticated identity (the source of its most recent command, so a coordinator configured on `0.0.0.0` still gets the reply across hosts). A reverse activation, or a nonce-less forward activation on the final stage, is dropped with a warn. `PendingEntryReply`, its map and the `pending_entry_replies` fields are gone. `ExchangeMatch::result_peer` is `stage_peers.last()` for `RunEntry` and the command's peer otherwise; `Ack`, `State`, `Error` keep `peer`. Frames per decoded token: 3 (`RunEntry`, forward activation, `RunResult`) instead of 4.
4. `render_peer_assignment_toml` (shared `render_toml_body`) is what a `--pp-peer` writes; the coordinator renderer and its callers are unchanged.
5. `Activate` / `Abort { detail }` after `Committed`, `ASSIGNMENT_PROTOCOL_VERSION` 2. The peer delivers only on `Activate`; `Abort`, another frame, close or the commit timeout reset `committed` and return it to listening. The coordinator activates all peers after every commit (a failed `Activate` write is an `AssignmentPushFailed` entry whose message says the other peers are running) or aborts every committed peer (detail cut to 1 KiB) when any commit failed. The peer logs a received detail bounded to 1 KiB and escaped to one line. The residual window is in the module docs.
6. `text_prefill_masks` returns `(None, None)` when `sliding_attn` is `None`.
7. `two_stage_split_matches_dense_after_front_trim_bitwise` (12-token prefill, `trim_front(4)` on every `Cache::Standard`, 3-token continuation, one decode step, bitwise against dense).

Docs: `docs/distributed.md` (assignment protocol, peer TOML header, reply authorization, 3-frame per-token flow); no Korean counterpart exists for these sections.

## Failing-first evidence

Run against the unchanged behaviour before each fix, green after:

- Item 1 `protected_stream_rejects_tampered_invalid_kind_as_frame_rejected`: `a tampered invalid kind must surface as FrameRejected, got: unknown message kind: 255`; `protected_stream_reports_authenticated_unknown_kind_distinctly`: `unexpected error: unknown message kind: 255`.
- Item 3 `final_stage_sends_run_result_to_coordinator_not_upstream`: `stage must answer within 5s (it may have died): Elapsed(())`; `run_result_from_last_stage_completes_forward`: `remote stage 10.1.1.1:1 timed out after 5s`; `run_result_from_entry_stage_is_dropped_in_multi_stage_pipeline`: `a RunResult from a stage other than the last must not complete the exchange`; `final_stage_sends_run_result_to_authenticated_coordinator_identity` (coordinator configured as `[::]:port`): `Elapsed(())` when the reply went to the configured address.
- Item 5 `peer_discards_committed_assignment_on_abort_and_activates_on_the_next`: `the peer delivered an assignment that was never activated`; `coordinator_aborts_committed_peers_when_another_commit_fails`: `the real peer delivered an assignment the coordinator aborted`.
- Item 6 `text_prefill_masks_without_sliding_layer_builds_no_mask`: `a stage without a sliding layer must not allocate a sliding mask`.

## Item 7 sensitivity

A temporary mutation (never committed, file restored byte for byte, sha256 `de5f8b65…c457036` before and after) gave the stage path an offset-sized global prefill mask (`Some(create_causal_mask(seq_len, first_cache_offset(caches, "full_attention")))` instead of `None`). The trim test then fails with `3-token continuation prefill after trim_front(4): stage logits differ from the dense logits (max abs diff 5.46875e-2)`. It is the only gemma4 stage test whose scenario has `offset != live_len` (12 vs 8).

## Decode throughput

`pipeline_remote_decode_bench` (qwen3-0.6b-4bit, 2 stages, loopback, 128 tokens, 7 timed repeats after a warm-up per run), main 433560c (A) and this branch (B) interleaved A B over 5 rounds on a quiet GB10, each run under `gpu-lock`. Both binaries were built with the bench's fixed port moved to 19990 (local only, not committed). Greedy tokens are identical in all 80 repeats.

| | per-round medians (tok/s) | median of medians | range |
|---|---|---|---|
| main | 120.67, 140.70, 131.38, 140.29, 136.73 | 136.73 | 120.67 to 140.70 |
| branch | 149.33, 142.75, 159.04, 156.94, 162.01 | 156.94 | 142.75 to 162.01 |

The branch is about 15% faster, and its slowest round beats main's fastest.

## Verification (GB10 spark-102, `--profile test-fast --features cuda,test-utils`, test binaries built outside the lock and run under `gpu-lock`, `--test-threads=1`, `MLXCEL_REQUIRE_MODELS=1 MLXCEL_SDPA_DETERMINISTIC=1`, at 7c8d340)

- `cargo fmt --check` and `cargo clippy ... --lib --tests -- -D warnings`: clean.
- `--lib distributed::`: 1494 passed, 4 ignored. `--lib models::gemma4`: 127 passed, 15 ignored.
- `zero_config_cluster_init --include-ignored`: 7/7 (the `zero_config_cli_*` rehearsals drive the real binaries through Activate).
- `pipeline_remote_real_models --include-ignored --skip glm_moe_dsa`: 10/10. `pipeline_server_remote_real_models --include-ignored --skip glm_moe_dsa`: 7/7. The skipped `glm_moe_dsa` tests need the 418.6 GB `glm5-4bit` checkpoint, which cannot load on a 128 GB GB10.
- `pipeline_ci_multi_stage_real_models --include-ignored`: 4/4, including `pipeline_multi_stage_three_host_real_model_parity` (logits now skip the middle stage) and the two-host smoke.
- `disaggregated_serving_e2e` 2/2, `disaggregated_router_e2e` 7/7, `disaggregated_e2e` 51/51 (all `--include-ignored`).


## Compatibility

The stage RPC messages change shape (`pending_entry_replies` is gone from `Ack` and `State`, `RunResult` comes from the final stage) while the handshake `PROTOCOL_VERSION` stays 4. A node built from main between #2705 and this PR therefore completes the handshake with a node built from this PR and then fails with exchange timeouts instead of a version error. No tagged release is affected (the latest is v0.8.0-beta.1, before the v3 and v4 handshakes); upgrade all nodes together.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority:medium Medium priority status:done Completed type:security Security vulnerability or fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(distributed): harden cluster pre-auth admission and rejections (#2674 follow-ups)

1 participant