S8 (Pass 2): wire grace-aware audio-membership admission

Closes S8 (High): inbound audio was authenticated by identity (remote_id) but
NOT by room membership, so a former member who knew a current member's endpoint
could reconnect on the audio ALPN and inject into / eavesdrop on the mix while
invisible in the roster. Now audio is admitted only for live gossip-roster
members (the senior+user-resolved GRACE-AWARE policy).

Transport (src/network/iroh_impl.rs):
- New per-session admitted_audio: HashSet<EndpointId> on Shared (internal state,
  no wire/serialization change). Cleared on disconnect_all.
- AudioRouter::accept consults audio_sender_admitted BEFORE ensure_supervisor —
  a non-member never gets a supervisor, sender handle, datagram reader, or
  outbound mix. Brief StdMutex check, released before the await (no RT lock).
- Pure apply_audio_admission_event(roster, peer, event) with AudioAdmissionEvent
  {RosterPresent insert, TransientDropGrace no-op, Remove}. Grace deliberately
  cannot ADD membership — it only preserves an already-admitted peer — so an
  unknown peer can't sneak in via a grace event. +3 lifecycle tests (on top of
  Pass-1's 4 predicate tests).
- admit/keep_for_reconnect_grace/remove/query methods for core to drive.

Core (src/core/mod.rs) — authority is core's VERIFIED gossip-roster events, not
transport connect/disconnect:
- PeerJoined / PeerUpdated: admit_audio_sender before connect_peer.
- PeerConnectionLost: keep_audio_sender_for_reconnect_grace (preserve through the
  existing RECONNECT_GRACE window — no audio cut on transient blips).
- gossip PeerLeft, transport ConnEvent::Left, grace-timer expiry: remove_audio_sender
  before disconnect_peer + jitter removal (removal-before-teardown bounds the
  in-flight-datagram race).
- datagram receiver: audio_sender_admitted gate before any jitter buffer (defense
  in depth against a datagram racing a removal). Mixer stays off the hot path.

Mid-join: a peer who dials audio before we've verified their signed Announce is
dropped (no "pending" admission, which would reintroduce the eavesdrop); their
reconnect loop recovers once the Announce admits them.

tests/transport_loopback.rs: admit both ends before connecting, mirroring the
production room-event order.

313 lib / clippy --all-targets / transport_loopback 4 / reconnect_eviction 6 /
release — all re-run green by the senior. Former-member-rejection + mid-join
recovery are verifiable only in a 2-machine call (senior's to run).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 04:41:37 -04:00
co-authored by Claude Opus 4.8
parent 10707152a3
commit 1adf8a97bb
3 changed files with 122 additions and 4 deletions
+9
View File
@@ -137,6 +137,7 @@ fn arm_grace_timer(
let handle = tokio::spawn(async move {
tokio::time::sleep(grace).await;
crate::log_msg(&format!("Reconnect grace expired; evicting peer {:?}", peer_id));
transport_evict.remove_audio_sender(peer_id);
transport_evict.disconnect_peer(peer_id).await;
jitter_evict.lock().await.remove(&peer_id);
// Scrub our internal state *before* announcing the eviction, so anything
@@ -370,6 +371,7 @@ impl ConnEventHandler {
// until the grace timer or the slow gossip Leave.
cancel_grace_timer(&self.grace_timers, &id);
self.seen_connected.lock().unwrap().remove(&id);
self.transport.remove_audio_sender(id);
self.transport.disconnect_peer(id).await;
self.jitter.lock().await.remove(&id);
let _ = self.ui_tx.send(UiEvent::PeerLeft { id }).await;
@@ -1239,6 +1241,9 @@ async fn run_core_loop(
};
while let Some((from_peer, bytes)) = datagram_rx.recv().await {
if !transport_recv.audio_sender_admitted(from_peer) {
continue;
}
if !audio_datagram_len_ok(bytes.len()) {
// Malformed (< sequence header) or oversized Opus payload.
continue;
@@ -1473,6 +1478,7 @@ async fn run_core_loop(
// A (re)join means the peer is back — cancel any
// pending reconnect grace timer before re-adding it.
cancel_grace_timer(&grace_timers_events, &peer_id);
transport_events.admit_audio_sender(peer_id);
// Establish the audio connection as soon as the peer
// is known (the transport dedupes the full-mesh race).
// Hand over the full address so reconnects can dial
@@ -1524,6 +1530,7 @@ async fn run_core_loop(
{
peers.remove(&peer_id);
}
transport_events.remove_audio_sender(peer_id);
transport_events.disconnect_peer(peer_id).await;
jitter_events.lock().await.remove(&peer_id);
let _ = ui_tx_events.send(UiEvent::PeerLeft { id: peer_id }).await;
@@ -1536,6 +1543,7 @@ async fn run_core_loop(
// it. Idempotent: an ordinary mute/unmute update just
// re-records the same address.
cancel_grace_timer(&grace_timers_events, &peer_id);
transport_events.admit_audio_sender(peer_id);
transport_events.connect_peer(state.addr.clone()).await;
// Auto-heal a friend's saved address (W7) on the
// re-announce too — this is the path that catches a
@@ -1577,6 +1585,7 @@ async fn run_core_loop(
// hasn't recovered within RECONNECT_GRACE. A gossip
// rejoin (PeerJoined/PeerUpdated) or a transport
// reconnect (ConnEvent::Connected) cancels it first.
transport_events.keep_audio_sender_for_reconnect_grace(peer_id);
let _ = ui_tx_events.send(UiEvent::PeerConnecting { id: peer_id }).await;
arm_grace_timer(
&grace_timers_events,
+110 -4
View File
@@ -56,6 +56,10 @@ struct Shared {
/// supervisor inserts its connection when the link comes up and removes it
/// when the link dies.
live_conns: StdMutex<HashMap<EndpointId, Connection>>,
/// Core-owned audio admission snapshot for this room session. It mirrors the
/// verified gossip roster plus peers still inside reconnect grace; transport
/// connections alone never mutate this set.
admitted_audio: StdMutex<HashSet<EndpointId>>,
incoming_tx: mpsc::Sender<(EndpointId, Bytes)>,
/// Best-effort link-state notifications for the UI (connecting / connected).
conn_events_tx: mpsc::Sender<ConnEvent>,
@@ -112,6 +116,16 @@ impl Shared {
crate::log_msg(&format!("Transport: stopped supervising peer {:?}", peer_id));
}
}
fn audio_sender_admitted(&self, peer_id: EndpointId) -> bool {
let roster = self.admitted_audio.lock().unwrap();
audio_sender_admitted(peer_id, &roster)
}
fn apply_audio_admission(&self, peer_id: EndpointId, event: AudioAdmissionEvent) {
let mut roster = self.admitted_audio.lock().unwrap();
apply_audio_admission_event(&mut roster, peer_id, event);
}
}
/// Why a peer's live-link wait woke up.
@@ -137,11 +151,39 @@ fn is_graceful_leave(err: &ConnectionError) -> bool {
/// Pure S8 membership decision: iroh already authenticated `remote` as the
/// connection's endpoint id, so audio admission is exactly live roster membership.
#[allow(dead_code)] // Design-first S8 seam; wiring waits for senior review.
pub(crate) fn audio_sender_admitted(remote: EndpointId, roster: &HashSet<EndpointId>) -> bool {
roster.contains(&remote)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AudioAdmissionEvent {
/// A signed gossip Announce/Update says the peer is in the live room roster.
RosterPresent,
/// Gossip reported a transient drop; keep admission during reconnect grace.
TransientDropGrace,
/// Graceful leave, transport Left eviction, or reconnect-grace expiry.
Remove,
}
pub(crate) fn apply_audio_admission_event(
roster: &mut HashSet<EndpointId>,
peer_id: EndpointId,
event: AudioAdmissionEvent,
) {
match event {
AudioAdmissionEvent::RosterPresent => {
roster.insert(peer_id);
}
AudioAdmissionEvent::TransientDropGrace => {
// Grace is not an authority to add membership; it only preserves an
// already-admitted peer until either rejoin or grace expiry.
}
AudioAdmissionEvent::Remove => {
roster.remove(&peer_id);
}
}
}
/// Owns a single peer's connection lifecycle for as long as the peer is in the
/// room: obtain a link, run the send/read loops, and on loss obtain a new one —
/// with capped backoff on the dialing side. The deterministic-initiator rule
@@ -340,10 +382,17 @@ impl iroh::protocol::ProtocolHandler for AudioRouter {
if shared.self_id.to_string() < peer_id.to_string() {
return Ok(());
}
if !shared.audio_sender_admitted(peer_id) {
crate::log_msg(&format!(
"Transport: rejected inbound audio from non-member {}",
crate::short_id(&peer_id.to_string())
));
return Ok(());
}
// Route the connection to this peer's supervisor (creating it if the
// inbound link beat the gossip join event). try_send keeps the
// protocol handler from ever blocking; a full queue only happens if
// links are churning, and the supervisor will get the next one.
// inbound link arrives after the signed gossip Announce admitted it).
// try_send keeps the protocol handler from ever blocking; a full queue
// only happens if links are churning, and the supervisor gets the next one.
let inbound_tx = shared.ensure_supervisor(peer_id).await;
if inbound_tx.try_send(connection).is_err() {
crate::log_msg(&format!("Transport: dropped inbound link from {:?} (queue full)", peer_id));
@@ -376,6 +425,7 @@ impl IrohTransport {
addrs: StdMutex::new(HashMap::new()),
peers: tokio::sync::Mutex::new(HashMap::new()),
live_conns: StdMutex::new(HashMap::new()),
admitted_audio: StdMutex::new(HashSet::new()),
incoming_tx,
conn_events_tx,
});
@@ -404,11 +454,35 @@ impl IrohTransport {
}
self.shared.senders.lock().unwrap().clear();
self.shared.addrs.lock().unwrap().clear();
self.shared.admitted_audio.lock().unwrap().clear();
// Give the CONNECTION_CLOSE frames a moment to flush before the caller
// shuts the endpoint/router down (the `conns` clones are still alive
// here, so the endpoint can still transmit them).
tokio::time::sleep(Duration::from_millis(150)).await;
}
/// Admit a peer to this session's audio plane. Core calls this from verified
/// gossip roster events; the transport never derives membership on its own.
pub fn admit_audio_sender(&self, peer_id: EndpointId) {
self.shared
.apply_audio_admission(peer_id, AudioAdmissionEvent::RosterPresent);
}
/// Preserve an already-admitted peer through the reconnect grace window.
pub fn keep_audio_sender_for_reconnect_grace(&self, peer_id: EndpointId) {
self.shared
.apply_audio_admission(peer_id, AudioAdmissionEvent::TransientDropGrace);
}
/// Remove a peer from audio admission before tearing down transport/jitter state.
pub fn remove_audio_sender(&self, peer_id: EndpointId) {
self.shared
.apply_audio_admission(peer_id, AudioAdmissionEvent::Remove);
}
pub fn audio_sender_admitted(&self, peer_id: EndpointId) -> bool {
self.shared.audio_sender_admitted(peer_id)
}
}
#[async_trait]
@@ -499,4 +573,36 @@ mod tests {
assert!(audio_sender_admitted(joining_peer, &roster));
}
#[test]
fn audio_admission_lifecycle_keeps_peer_through_transient_grace() {
let peer = endpoint_id();
let mut roster = HashSet::new();
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::RosterPresent);
assert!(audio_sender_admitted(peer, &roster));
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::TransientDropGrace);
assert!(audio_sender_admitted(peer, &roster));
}
#[test]
fn audio_admission_lifecycle_does_not_add_unknown_peer_on_grace_event() {
let peer = endpoint_id();
let mut roster = HashSet::new();
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::TransientDropGrace);
assert!(!audio_sender_admitted(peer, &roster));
}
#[test]
fn audio_admission_lifecycle_removes_peer_on_leave_or_grace_expiry() {
let peer = endpoint_id();
let mut roster = HashSet::from([peer]);
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::Remove);
assert!(!audio_sender_admitted(peer, &roster));
}
}
+3
View File
@@ -169,6 +169,9 @@ async fn loopback_sequenced_audio_reaches_peer_and_decodes() {
b.lookup.add_endpoint_info(a.endpoint.addr());
let a_id = a.endpoint.id();
let b_id = b.endpoint.id();
a.transport.admit_audio_sender(b_id);
b.transport.admit_audio_sender(a_id);
// Subscribe to incoming datagrams on B before any are sent.
let mut b_rx = b.transport.receive_datagrams().await.expect("subscribe B");