//! Presence control transport (W7 P4) — the I/O edge for the friends-only idle //! listener. The *policy* (who we answer, what we report) lives in pure form in //! [`crate::presence`]; this module is just the wire: bind/probe/serve a //! ping→pong over a dedicated ALPN, adapted from pixelpass's proven control plane //! (same iroh version). //! //! Shape: **request/response, one exchange per connection.** A prober opens a //! bi-stream, writes a [`ControlMsg::Ping`], finishes its send side, and reads //! the peer's [`ControlMsg::Pong`] back. The server accepts, reads the ping, asks //! its injected `handler` (which encapsulates [`crate::presence::should_answer`] //! plus building the pong from current room state) what to reply, writes it, and //! closes. A non-friend / invisible peer gets `None`, so the stream is closed //! with no reply and the listener never reveals anything to a stranger. //! //! **Deferred to a later slice (the hard integration):** spawning `serve` on a //! persistent endpoint that lives OUTSIDE the per-join room session, and the //! outbound ping scheduler. The endpoint fork was DECIDED 2026-06-15 by a //! throwaway spike: a **single persistent endpoint** (option b), NOT a second //! always-on endpoint sharing our id (option a). The spike proved two endpoints //! sharing one `SecretKey` can't coexist — inbound connections all land on one //! instance and the other's ALPN fails the QUIC handshake ("error 120"). So this //! `serve` will run as the FRIENDS_ALPN handler on the app's one persistent //! `Router`; it still works against whatever `Endpoint` it's handed. use crate::presence::ControlMsg; use anyhow::{Context, Result, bail}; use iroh::endpoint::{Incoming, VarInt}; use iroh::{Endpoint, EndpointAddr, EndpointId}; use std::sync::Arc; use std::time::Duration; /// ALPN for the friends presence/control plane. Separate from the audio/gossip /// ALPNs so a control dial never lands on a bare room endpoint and vice versa. pub const FRIENDS_ALPN: &[u8] = crate::protocol::FRIENDS_ALPN; /// Upper bound on a single control message — generous for a Pong carrying a /// member ticket (~300 chars), but rejects a peer trying to make us buffer a /// huge blob. const MAX_MSG: usize = 64 * 1024; /// Bound on each network phase so a half-dead peer or relay can't park us. const IO_TIMEOUT: Duration = Duration::from_secs(10); fn encode(msg: &ControlMsg) -> Result> { serde_json::to_vec(msg).context("failed to encode control message") } fn decode(bytes: &[u8]) -> Result { serde_json::from_slice(bytes).context("failed to decode control message") } /// Probe `peer` for presence: send a `Ping`, return their authenticated id and /// `Pong`. An error means no usable reply (offline / unreachable / refused / /// malformed) — the caller treats that as "appears offline". `peer` is usually a /// bare [`EndpointId`] (friends store the stable id); a full [`EndpointAddr`] is /// also accepted (and used by hermetic tests). pub async fn probe(endpoint: &Endpoint, peer: impl Into) -> Result<(EndpointId, ControlMsg)> { let conn = tokio::time::timeout(IO_TIMEOUT, endpoint.connect(peer, FRIENDS_ALPN)) .await .context("timed out connecting to peer")? .context("failed to connect to peer")?; let from = conn.remote_id(); let io = async { let (mut send, mut recv) = conn.open_bi().await.context("failed to open control stream")?; send.write_all(&encode(&ControlMsg::Ping)?) .await .context("failed to write ping")?; send.finish().context("failed to finish ping stream")?; let bytes = recv .read_to_end(MAX_MSG) .await .context("peer closed the stream without replying")?; decode(&bytes) }; let result = tokio::time::timeout(IO_TIMEOUT, io) .await .context("timed out awaiting pong")?; conn.close(VarInt::from_u32(0), b"done"); result.map(|msg| (from, msg)) } /// A reply policy: given the *authenticated* remote id, decide whether and how to /// answer a ping. `None` = don't answer (not a friend, or we're invisible). This /// is where [`crate::presence::should_answer`] + the Pong contents are wired in /// by the caller; keeping it injected means this transport carries no policy. pub type Handler = Arc Option + Send + Sync>; /// Run the presence accept loop on `endpoint`, replying to each ping per /// `handler`. Returns when the endpoint stops accepting (i.e. it was closed). pub async fn serve(endpoint: Endpoint, handler: Handler) { while let Some(incoming) = endpoint.accept().await { let handler = handler.clone(); tokio::spawn(async move { if let Err(e) = handle(incoming, handler).await { crate::log_msg(&format!("presence: inbound connection failed: {e:#}")); } }); } } async fn handle(incoming: Incoming, handler: Handler) -> Result<()> { let conn = incoming.await.context("inbound connection failed")?; exchange(&conn, &handler).await } /// One ping→pong exchange on an already-accepted connection: read the ping, /// ask the policy, reply (or reveal nothing), close. Shared by the standalone /// [`serve`] loop and the [`FriendsProtocol`] router handler. async fn exchange(conn: &iroh::endpoint::Connection, handler: &Handler) -> Result<()> { // The authenticated remote id — NOT anything the peer puts in the payload. let from = conn.remote_id(); let Some(reply) = handler(from) else { conn.close(VarInt::from_u32(0), b"not authorized"); return Ok(()); }; let io = async { let (mut send, mut recv) = conn.accept_bi().await.context("failed to accept stream")?; let bytes = recv.read_to_end(MAX_MSG).await.context("failed to read ping")?; match decode(&bytes)? { ControlMsg::Ping => {} other => bail!("expected a ping, got {other:?}"), } send.write_all(&encode(&reply)?) .await .context("failed to write pong")?; send.finish().context("failed to finish reply stream")?; Ok::<_, anyhow::Error>(()) }; tokio::time::timeout(IO_TIMEOUT, io) .await .context("timed out handling ping")??; let _ = tokio::time::timeout(IO_TIMEOUT, conn.closed()).await; Ok(()) } /// The live friends-presence listener as an iroh [`ProtocolHandler`], registered /// once on the app's single persistent `Router` for [`FRIENDS_ALPN`]. Because the /// Router owns `endpoint.accept()`, the listener can't be the standalone [`serve`] /// loop (that would compete for accepts); this delegates each inbound connection to /// the same [`exchange`] body, with the reply policy injected as a [`Handler`] /// (which wraps [`crate::presence::should_answer`] + builds the Pong). Mirrors the /// `AudioRouter` pattern from the B1 persistent-endpoint refactor. #[derive(Clone)] pub struct FriendsProtocol { handler: Handler, } impl FriendsProtocol { pub fn new(handler: Handler) -> Self { Self { handler } } } impl std::fmt::Debug for FriendsProtocol { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("FriendsProtocol").finish_non_exhaustive() } } impl iroh::protocol::ProtocolHandler for FriendsProtocol { fn accept( &self, connection: iroh::endpoint::Connection, ) -> impl std::future::Future> + Send { let handler = self.handler.clone(); async move { // A failed exchange (malformed ping, timeout, etc.) is logged, not // surfaced as an accept error — one bad prober shouldn't disturb the // listener. Returning Ok keeps the router loop healthy. if let Err(e) = exchange(&connection, &handler).await { crate::log_msg(&format!("presence: inbound friends exchange failed: {e:#}")); } Ok(()) } } } #[cfg(test)] mod tests { use super::*; use crate::presence::{ControlMsg, RoomPresence}; async fn bind() -> Endpoint { iroh::Endpoint::builder(iroh::endpoint::presets::N0) .secret_key(iroh::SecretKey::generate()) .alpns(vec![FRIENDS_ALPN.to_vec()]) .bind() .await .unwrap() } /// End-to-end over two real iroh endpoints on this machine. Ignored by /// default (binds endpoints + waits on the relay → slow, network-dependent). /// Run with: cargo test -- --ignored presence_net #[tokio::test] #[ignore = "binds real iroh endpoints; run on demand"] async fn loopback_ping_pong_and_friends_only() { let server = bind().await; let prober = bind().await; server.online().await; prober.online().await; let server_addr = server.addr(); let prober_id = prober.addr().id; // The server answers ONLY the prober's id, reporting a room. let allowed = prober_id; let handler: Handler = Arc::new(move |from| { if from == allowed { Some(ControlMsg::Pong { room: Some(RoomPresence { name: "HangOut".into(), ticket: "t".into() }), }) } else { None // stranger -> no reply } }); let server_ep = server.clone(); let serve_task = tokio::spawn(async move { serve(server_ep, handler).await }); // The allowed prober gets a Pong with the room. let (from, pong) = tokio::time::timeout(Duration::from_secs(15), probe(&prober, server_addr.clone())) .await .expect("probe timed out") .expect("probe failed"); assert_eq!(from, server_addr.id); match pong { ControlMsg::Pong { room: Some(r) } => assert_eq!(r.name, "HangOut"), other => panic!("expected Pong with a room, got {other:?}"), } // A stranger (fresh id) gets an empty reply -> decode fails -> Err. let stranger = bind().await; stranger.online().await; let res = tokio::time::timeout(Duration::from_secs(15), probe(&stranger, server_addr)) .await .expect("stranger probe timed out"); assert!(res.is_err(), "a non-friend must not get a usable reply"); server.close().await; prober.close().await; stranger.close().await; serve_task.abort(); } }