Skip to content

fix(cluster): harden admin proof, dual-role drain, delete timeout - #151

Merged
AlexanderWagnerDev merged 6 commits into
mainfrom
fix/bug-hunter-cluster-admin-drain
Aug 10, 2026
Merged

fix(cluster): harden admin proof, dual-role drain, delete timeout#151
AlexanderWagnerDev merged 6 commits into
mainfrom
fix/bug-hunter-cluster-admin-drain

Conversation

@AlexanderWagnerDev

@AlexanderWagnerDev AlexanderWagnerDev commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Require admin_proof for all control-plane ClientWrite commands (including ownership) and for peer AdminDrain/AdminResume.
  • Ignore spoofed DrainStream/RevokeViewer unless local DB already reflects pending delete / missing viewer.
  • Dual-role delete: release only the deleted role; clear pending_relay; kick only when no authorized role remains.
  • Cap async delete drain (300s) and abandon_roles_for_stream so finalize cannot hang on stuck ConnState.

Companion panel PR: OpenRTMP/librtmp2-server-panel#114

Test plan

  • CI --features cluster (cluster_security, dual-role drain unit tests)
  • Manual: follower drain/resume of another node still works with valid API token
  • Manual: peer without proof cannot AdminDrain
  • Manual: delete stream with live dual-role conn keeps publisher when only play stream deleted
  • Manual: stuck release path eventually finalizes after drain timeout

Summary by CodeRabbit

  • Security

    • All durable control-plane changes now require valid administrator proof, including stream ownership, token, and stream-creation actions.
    • Administrative drain and resume requests are authenticated before processing.
    • Invalid or missing proofs are rejected.
  • Bug Fixes

    • Network cleanup hints are accepted only when confirmed by local durable state.
    • Stream deletion now handles publisher and player roles independently, preserving valid connections where possible.
    • Deletion draining continues safely after a maximum wait period, preventing stalled cleanup.

Co-authored-by: Cursor <cursoragent@cursor.com>
@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because your trial has ended. Ask your workspace admin to add credits to resume reviews. Manage billing

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AlexanderWagnerDev, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 20 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e1c1c650-8ef8-43e1-ba22-8ce8b4147429

📥 Commits

Reviewing files that changed from the base of the PR and between 01b71ca and 87951f4.

📒 Files selected for processing (7)
  • Dockerfile
  • src/cluster/command.rs
  • src/cluster/manager.rs
  • src/http.rs
  • src/rtmp_bridge.rs
  • src/server.rs
  • tests/cluster_security.rs
📝 Walkthrough

Walkthrough

The change requires administrator proofs for durable cluster mutations and drain/resume forwarding. It validates network deletion hints against durable state, adds deletion-drain timeouts, and preserves surviving RTMP roles when only one role is deleted.

Changes

Cluster security and stream cleanup

Layer / File(s) Summary
Admin proof enforcement
src/cluster/command.rs, src/cluster/manager.rs, src/cluster/network.rs, tests/cluster_security.rs
All ClusterCommand mutations require administrator proof. Control-plane drain and resume messages carry signed proofs, and network handlers validate them.
Durable deletion validation
src/db.rs, src/cluster/manager.rs, src/http.rs
The database exposes stream pending-delete and viewer existence checks. Network hints require matching durable state. Stream deletion draining now has a bounded timeout.
Per-connection role cleanup
src/rtmp_bridge.rs, src/server.rs
Publisher and player roles are checked and released independently. Surviving roles remain active, while connections with no authorized role are rejected. Tests cover dual-role cleanup.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ClusterManager
  participant NetworkHandler
  participant Db
  participant RtmpBridge
  participant Connection
  ClusterManager->>NetworkHandler: Forward proof-bearing control message
  NetworkHandler->>Db: Validate deletion or viewer state
  Db-->>NetworkHandler: Return durable state
  NetworkHandler->>RtmpBridge: Apply accepted cleanup
  RtmpBridge->>Connection: Remove deleted roles
  Connection-->>RtmpBridge: Preserve surviving role
Loading

Possibly related PRs

Suggested labels: 🕐 40+ Minutes

Suggested reviewers: claude

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the PR's main changes: stronger admin proof enforcement, dual-role stream draining, and delete-drain timeouts.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/bug-hunter-cluster-admin-drain

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Co-authored-by: Cursor <cursoragent@cursor.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/http.rs (1)

3427-3437: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

This test no longer pins the normal drain path.

DELETE_DRAIN_TIMEOUT is 200 ms under cfg(test). The test sleeps DELETE_DRAIN_WARN_AFTER + 50ms (100 ms) before it calls on_close(1). Under scheduler jitter the elapsed time can cross 200 ms first, and the loop then takes the timeout branch. Both branches finalize the delete and clear the marker, so the assertions still pass, but the test can pass without exercising the drain-completes path it is named for.

Either raise the test DELETE_DRAIN_TIMEOUT well above the warn interval, or assert which branch ran.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/http.rs` around lines 3427 - 3437, Adjust the test around the RTMP bridge
on_close call so it reliably exercises the normal drain-completes path instead
of potentially reaching DELETE_DRAIN_TIMEOUT first. Prefer configuring the test
DELETE_DRAIN_TIMEOUT well above the warn interval, or otherwise assert that the
drain branch ran before verifying deletion and marker cleanup.
🧹 Nitpick comments (6)
src/cluster/command.rs (1)

77-86: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider making the constant-true predicate explicit.

requires_admin_proof now ignores self. The let _ = self; line exists only to silence the unused-parameter path. Two alternatives keep the call sites unchanged and remove the placeholder:

  • Add #[allow(clippy::unused_self)] with the existing doc comment.
  • Replace call sites with an associated constant and delete the method.

Keeping the method is fine if you plan to reintroduce per-command exemptions later.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cluster/command.rs` around lines 77 - 86, Make the constant-true
requires_admin_proof predicate explicit by removing the let _ = self placeholder
and adding #[allow(clippy::unused_self)] to the method. Preserve the existing
method name, signature, documentation, and call sites.
src/server.rs (2)

508-512: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Drop the redundant prefilter call.

drain_deleted_stream_roles recomputes exactly what conn_references_deleted_stream computes: the publisher stream id, the player stream id, and the same stream_ids_for_conn fallback. It returns false when nothing matches. The && therefore performs the same three bridge lookups twice per connection per poll tick.

The larger risk is drift. If the prefilter ever becomes narrower than the drain function, roles stop being drained with no other symptom.

Call drain_deleted_stream_roles alone. Keep conn_references_deleted_stream only if another caller still needs it.

♻️ Proposed simplification
         // Tear down roles whose stream was deleted. Dual-role conns keep the
         // surviving role; kick only when nothing authorized remains.
-        if conn_references_deleted_stream(rtmp_bridge, conn_id, entry, deleted_now)
-            && drain_deleted_stream_roles(conn, entry, rtmp_bridge, conn_id, deleted_now)
-        {
+        if drain_deleted_stream_roles(conn, entry, rtmp_bridge, conn_id, deleted_now) {
             reject_indices.push(idx);
             continue;
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server.rs` around lines 508 - 512, Remove the redundant
conn_references_deleted_stream prefilter from the teardown condition and call
drain_deleted_stream_roles directly, preserving its existing arguments and kick
behavior. Search for other callers of conn_references_deleted_stream and remove
it only if unused after this change.

1356-1368: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the relay route as well.

The test covers the role and tracker state. Lines 306-308 of drain_deleted_stream_roles also rewrite conn.relay_key to the surviving publisher stream. Add that assertion so a regression in the relay rewrite is caught here.

💚 Proposed addition
         assert!(
             conn.pending_relay.is_empty(),
             "queued play frames must not survive onto the publisher route"
         );
+        assert_eq!(conn.relay_key, "s1");
+        assert!(conn.relay_enabled);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server.rs` around lines 1356 - 1368, Extend the test assertions after
drain_deleted_stream_roles to verify that conn.relay_key has been rewritten to
the surviving publisher stream, matching the expected relay identifier for
stream "s1".
src/cluster/network.rs (1)

854-875: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fold the two proof checks into one match arm.

The payload strings match drain_node and resume_node in src/cluster/manager.rs exactly, and an omitted proof deserializes to "", which fails verification. The gate is fail-closed.

Two points on the shape of the check:

  • A single match on &admin reads better than two sequential if let blocks and keeps the payload construction next to its variant.
  • The proof binds only node_id. It carries no nonce or expiry, so an authenticated member that captured an AdminDrain frame can replay it later. force_drain and force_resume are idempotent, so the impact is limited to re-triggering an admission state an operator already requested. If you want replay resistance here, include the handshake nonce or a timestamp in the signed payload.
♻️ Proposed refactor for the proof checks
-            if let ControlMessage::AdminDrain { node_id, proof } = &admin {
-                let payload = format!("AdminDrain:{node_id}");
-                if !verify_admin_proof(proof, &payload) {
-                    return Err(std::io::Error::other("invalid admin drain proof"));
-                }
-            }
-            if let ControlMessage::AdminResume { node_id, proof } = &admin {
-                let payload = format!("AdminResume:{node_id}");
-                if !verify_admin_proof(proof, &payload) {
-                    return Err(std::io::Error::other("invalid admin resume proof"));
-                }
-            }
+            match &admin {
+                ControlMessage::AdminDrain { node_id, proof } => {
+                    if !verify_admin_proof(proof, &format!("AdminDrain:{node_id}")) {
+                        return Err(std::io::Error::other("invalid admin drain proof"));
+                    }
+                }
+                ControlMessage::AdminResume { node_id, proof } => {
+                    if !verify_admin_proof(proof, &format!("AdminResume:{node_id}")) {
+                        return Err(std::io::Error::other("invalid admin resume proof"));
+                    }
+                }
+                _ => {}
+            }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cluster/network.rs` around lines 854 - 875, Replace the two sequential
proof-checking if-let blocks in the ControlMessage admin handling arm with one
match on &admin, constructing the exact drain_node and resume_node payloads for
AdminDrain and AdminResume respectively and rejecting invalid or omitted proofs
through the existing fail-closed error path. Leave other control-message
variants unchanged; replay resistance is optional and should only be added if
the signed payload can consistently include a handshake nonce or timestamp.
tests/cluster_security.rs (1)

46-54: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test that pins the drain/resume proof payload format.

This test covers the client-write payload well. The new wire contract in this PR is the drain/resume payload string. src/cluster/manager.rs builds AdminDrain:{node_id}, and src/cluster/network.rs rebuilds the same string to verify. Nothing fails if one side changes the format; drain and resume would silently stop working across nodes.

Add a small test that asserts the exact payload strings so the two sides cannot drift.

💚 Proposed test
#[test]
fn admin_drain_resume_proof_payload_format_is_stable() {
    let token = "api-token-for-tests-only";
    assert!(secrets_equal(
        &admin_proof(token, "AdminDrain:7"),
        &admin_proof(token, &format!("AdminDrain:{}", 7u64))
    ));
    assert!(!secrets_equal(
        &admin_proof(token, "AdminDrain:7"),
        &admin_proof(token, "AdminResume:7")
    ));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/cluster_security.rs` around lines 46 - 54, Add a test near
empty_client_write_proof_does_not_match_signed_payload named
admin_drain_resume_proof_payload_format_is_stable that verifies admin_proof uses
the exact “AdminDrain:7” payload format, matches the equivalent formatted node
ID, and does not match the “AdminResume:7” payload.
src/db.rs (1)

801-817: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider reusing this helper in stream_delete_if_pending.

Lines 823-837 run the same SELECT pending_delete FROM streams WHERE id=? query with the same error logging. The new helper covers that read.

One caveat blocks a direct substitution: stream_delete_if_pending returns Some(false) for a missing row and None for a DB error, while stream_pending_delete returns None for both. To share the code, the helper would need to distinguish the two cases, for example by returning DbLookup<bool>.

The current single caller stream_allows_network_drain is correct as written, because it checks existence with stream_get first, and a None from a DB error rejects the drain.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/db.rs` around lines 801 - 817, Refactor stream_pending_delete and
stream_delete_if_pending to reuse a shared lookup result that distinguishes a
missing row from a database error, such as DbLookup<bool>. Preserve
stream_delete_if_pending’s Some(false) result for missing rows and None for
errors, while retaining stream_allows_network_drain’s existing behavior for
database failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/cluster/manager.rs`:
- Around line 698-702: Defer the client_write_admin_proof call in block_on_write
until the ForwardToLeader branch, so local-leader raft.client_write operations
proceed without requiring a session-hook token. Preserve proof generation and
forwarding behavior for writes that must be sent to another leader.

In `@src/rtmp_bridge.rs`:
- Around line 538-577: The delete-drain timeout teardown is incomplete. In
src/rtmp_bridge.rs lines 538-577, update abandon_roles_for_stream to collect
affected ConnId values while holding the conns lock, release the guard, then
call try_release_ownership_for_conn for abandoned publishers and
maybe_unsubscribe_remote_play for abandoned players, following
force_unpublish_stream; return the abandoned connection IDs. In src/http.rs
lines 1245-1254, use those IDs to force-close connections through the RTMP poll
loop, and remove the redundant deleted_streams insertion because the insertion
at line 1343 already handles it.

In `@src/server.rs`:
- Around line 298-311: Guard the relay-key update in the has_pub/has_play branch
so stream IDs still present in deleted_now are not assigned to conn.relay_key or
enabled for relaying. Preserve the existing assignment for valid stream IDs and
continue clearing pending relay frames.

---

Outside diff comments:
In `@src/http.rs`:
- Around line 3427-3437: Adjust the test around the RTMP bridge on_close call so
it reliably exercises the normal drain-completes path instead of potentially
reaching DELETE_DRAIN_TIMEOUT first. Prefer configuring the test
DELETE_DRAIN_TIMEOUT well above the warn interval, or otherwise assert that the
drain branch ran before verifying deletion and marker cleanup.

---

Nitpick comments:
In `@src/cluster/command.rs`:
- Around line 77-86: Make the constant-true requires_admin_proof predicate
explicit by removing the let _ = self placeholder and adding
#[allow(clippy::unused_self)] to the method. Preserve the existing method name,
signature, documentation, and call sites.

In `@src/cluster/network.rs`:
- Around line 854-875: Replace the two sequential proof-checking if-let blocks
in the ControlMessage admin handling arm with one match on &admin, constructing
the exact drain_node and resume_node payloads for AdminDrain and AdminResume
respectively and rejecting invalid or omitted proofs through the existing
fail-closed error path. Leave other control-message variants unchanged; replay
resistance is optional and should only be added if the signed payload can
consistently include a handshake nonce or timestamp.

In `@src/db.rs`:
- Around line 801-817: Refactor stream_pending_delete and
stream_delete_if_pending to reuse a shared lookup result that distinguishes a
missing row from a database error, such as DbLookup<bool>. Preserve
stream_delete_if_pending’s Some(false) result for missing rows and None for
errors, while retaining stream_allows_network_drain’s existing behavior for
database failures.

In `@src/server.rs`:
- Around line 508-512: Remove the redundant conn_references_deleted_stream
prefilter from the teardown condition and call drain_deleted_stream_roles
directly, preserving its existing arguments and kick behavior. Search for other
callers of conn_references_deleted_stream and remove it only if unused after
this change.
- Around line 1356-1368: Extend the test assertions after
drain_deleted_stream_roles to verify that conn.relay_key has been rewritten to
the surviving publisher stream, matching the expected relay identifier for
stream "s1".

In `@tests/cluster_security.rs`:
- Around line 46-54: Add a test near
empty_client_write_proof_does_not_match_signed_payload named
admin_drain_resume_proof_payload_format_is_stable that verifies admin_proof uses
the exact “AdminDrain:7” payload format, matches the equivalent formatted node
ID, and does not match the “AdminResume:7” payload.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 71533b8b-1d5e-45e2-a01a-1cea916ed6ae

📥 Commits

Reviewing files that changed from the base of the PR and between e8e21cd and 01b71ca.

📒 Files selected for processing (8)
  • src/cluster/command.rs
  • src/cluster/manager.rs
  • src/cluster/network.rs
  • src/db.rs
  • src/http.rs
  • src/rtmp_bridge.rs
  • src/server.rs
  • tests/cluster_security.rs

Comment thread src/cluster/manager.rs Outdated
Comment thread src/rtmp_bridge.rs
Comment thread src/server.rs
AlexanderWagnerDev and others added 4 commits August 10, 2026 16:28
glibc binary + Alpine/gcompat exited 127; panel Integration needs same-libc stages. Gate unused eviction_stream_id for clippy -D warnings.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Defer admin_proof to ForwardToLeader so local raft writes work before session hooks. Complete delete-drain timeout teardown (ownership, unsubscribe, force-close). Guard relay_key against deleted streams; raise test DELETE_DRAIN_TIMEOUT.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@AlexanderWagnerDev
AlexanderWagnerDev merged commit dadeb9f into main Aug 10, 2026
14 checks passed
@AlexanderWagnerDev
AlexanderWagnerDev deleted the fix/bug-hunter-cluster-admin-drain branch August 10, 2026 14:57
@AlexanderWagnerDev AlexanderWagnerDev mentioned this pull request Aug 10, 2026
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant