A validly-signed gossip payload rejected only by the 120s replay freshness window (GossipReject::OutOfWindow) now drives a room-level "clocks out of sync" warning banner, instead of silently dropping the peer so the room shows "1 in room" with no error. Observe-only: verify_gossip's accept/reject decision and GOSSIP_FRESHNESS_MS are unchanged; the payload is still dropped exactly as before. The warning is gated strictly on OutOfWindow (which, because the signature is verified first, implies a genuine authenticated peer whose clock is skewed), never on BadSignature. Policy lives in a pure, unit-tested ClockSkewMonitor seam with injected now_ms: >=3 OutOfWindow drops from the same author within 60s warn once, 5-min per-author cooldown, bounded/pruned author map. The warning rides the existing in-process RoomEvent -> UiEvent -> transient-banner path (no wire/serialization or dependency change). Implemented by Codex (gpt-5.5), senior-audited against the 5-point checklist and independently verified (452 lib tests, clippy --all-targets clean, release build green). Tests-green only; a 2-machine deliberate-skew field test is still owed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
449 lines
20 KiB
Rust
449 lines
20 KiB
Rust
use iroh::{EndpointId, EndpointAddr};
|
|
use bytes::Bytes;
|
|
use thiserror::Error;
|
|
use tokio::sync::mpsc::Receiver;
|
|
use async_trait::async_trait;
|
|
use serde::{Serialize, Deserialize};
|
|
use std::str::FromStr;
|
|
|
|
#[derive(Error, Debug)]
|
|
pub enum NetError {
|
|
#[error("Failed to initialize network: {0}")]
|
|
Init(String),
|
|
#[error("Failed to connect/dial peer: {0}")]
|
|
Connection(String),
|
|
#[error("Gossip swarm error: {0}")]
|
|
Gossip(String),
|
|
#[error("Serialization / Deserialization error: {0}")]
|
|
Serde(String),
|
|
#[error("Invalid ticket: {0}")]
|
|
InvalidTicket(String),
|
|
#[error("Other network error: {0}")]
|
|
Other(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct PeerState {
|
|
pub name: String,
|
|
pub is_muted: bool,
|
|
pub addr: iroh::EndpointAddr,
|
|
/// When this peer is sharing their screen, the pixelpass relay ticket a
|
|
/// viewer needs to watch it; `None` when not sharing. Riding presence means
|
|
/// the existing gossip re-announce delivers it to late joiners for free, and
|
|
/// a `PeerUpdated` fires automatically on share start/stop. Defaulted so
|
|
/// older configs / peers that predate the field still deserialize.
|
|
#[serde(default)]
|
|
pub sharing: Option<String>,
|
|
/// This peer's chosen avatar (W4). Rides presence so it reaches everyone
|
|
/// (incl. late joiners) with no server, like `sharing`. Defaulted so older
|
|
/// peers/configs that predate the field still deserialize (→ monogram).
|
|
#[serde(default)]
|
|
pub avatar: crate::avatar::Avatar,
|
|
/// The game this peer is currently playing, as a display string only (shown as
|
|
/// `Playing <name>` next to their avatar). Opt-in and **untrusted** like
|
|
/// `name`: sanitized + length-capped at the gossip ingest boundary. `None` when
|
|
/// the peer isn't sharing a game (feature off / nothing detected). Only the
|
|
/// display string rides the wire — never the appid or detection source, to
|
|
/// avoid fingerprinting and coupling the protocol to detector internals.
|
|
/// Defaulted so peers/configs predating the field still deserialize.
|
|
#[serde(default)]
|
|
pub game: Option<String>,
|
|
}
|
|
|
|
/// The locally-owned, "sticky" pieces of our own presence: the identity fields
|
|
/// that change only on explicit user action and persist for the whole core
|
|
/// session. The remaining `PeerState` fields are *volatile* — mute state, current
|
|
/// `addr`, and the active screen-share ticket are read fresh at each announce — so
|
|
/// they are passed into [`SelfPresence::to_state`] rather than stored here.
|
|
///
|
|
/// This is the single source of truth for building our own `PeerState`: core
|
|
/// reconstructs self-state in several command branches (join, mute toggle, avatar
|
|
/// change, screen-share start/stop), and centralizing the `PeerState` literal here
|
|
/// means a new presence field is added in exactly one place instead of at every
|
|
/// call site.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct SelfPresence {
|
|
pub name: String,
|
|
pub avatar: crate::avatar::Avatar,
|
|
/// The display label of the game we're currently broadcasting, or `None` when
|
|
/// game presence is off / nothing is detected. Already sanitized + capped
|
|
/// (see `crate::sanitize::sanitize_game_label`) before being stored here, so
|
|
/// the outgoing announce carries a safe value.
|
|
pub game: Option<String>,
|
|
}
|
|
|
|
impl SelfPresence {
|
|
/// Combine the sticky identity fields with the volatile per-announce fields
|
|
/// (`is_muted`, current `addr`, active-share `sharing` ticket) into a full
|
|
/// `PeerState` ready to announce over the gossip presence plane.
|
|
pub fn to_state(
|
|
&self,
|
|
is_muted: bool,
|
|
addr: iroh::EndpointAddr,
|
|
sharing: Option<String>,
|
|
) -> PeerState {
|
|
PeerState {
|
|
name: self.name.clone(),
|
|
is_muted,
|
|
addr,
|
|
sharing,
|
|
avatar: self.avatar.clone(),
|
|
game: self.game.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum RoomEvent {
|
|
PeerJoined(EndpointId, PeerState),
|
|
/// A peer left *gracefully* (it broadcast a `Leave`). Evict it immediately.
|
|
PeerLeft(EndpointId),
|
|
PeerUpdated(EndpointId, PeerState),
|
|
/// A peer's gossip neighbour link dropped without a graceful `Leave` (network
|
|
/// blip, crash, walked out of range). This is transient: the audio supervisor
|
|
/// keeps redialing, so the core marks the peer "reconnecting" and only evicts
|
|
/// it if the link hasn't recovered within the reconnect grace window. Distinct
|
|
/// from `PeerLeft` precisely so a momentary `NeighborDown` can't tear down the
|
|
/// reconnect path the way it used to.
|
|
PeerConnectionLost(EndpointId),
|
|
/// A validly signed gossip payload was rejected only because its timestamp is
|
|
/// outside the replay-protection window. The peer is not in the roster yet,
|
|
/// so this surfaces as a room-level warning instead of a peer-card state.
|
|
ClockSkewSuspected { author: EndpointId, skew_ms: i64 },
|
|
/// A peer sent a room text-chat message. Carries the sender's id, their
|
|
/// display name (embedded so it shows even without a presence entry), the
|
|
/// text, and a sender-stamped millisecond timestamp.
|
|
ChatMessage {
|
|
from: EndpointId,
|
|
name: String,
|
|
text: String,
|
|
ts: u64,
|
|
/// Optional file attachment descriptor; the bytes are fetched off-gossip
|
|
/// on the file plane. Already filename-sanitized + size-capped on ingest.
|
|
attachment: Option<crate::files::ChatAttachment>,
|
|
},
|
|
}
|
|
|
|
/// Transport-level link state for a peer, surfaced so the UI can show when a
|
|
/// peer's audio connection is being (re)established versus actually carrying
|
|
/// audio. Distinct from `RoomEvent`: a peer can be present in the gossip room
|
|
/// while its audio link is momentarily down and reconnecting.
|
|
#[derive(Debug, Clone)]
|
|
pub enum ConnEvent {
|
|
/// No live audio link yet — initial connect or reconnecting after a drop.
|
|
Connecting(EndpointId),
|
|
/// A live audio link is established and carrying datagrams.
|
|
Connected(EndpointId),
|
|
/// The peer closed its link *gracefully* (an explicit QUIC application close,
|
|
/// which only happens on an intentional leave/quit — a network drop yields a
|
|
/// timeout, not this). Distinct from a transient drop so the core can evict
|
|
/// the peer immediately instead of waiting out the reconnect grace window.
|
|
/// This is the prompt, reliable leave signal; the gossip `Leave` is too slow.
|
|
Left(EndpointId),
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub struct PeerSpeakTicket {
|
|
pub host_addr: iroh::EndpointAddr,
|
|
pub topic_id: [u8; 32],
|
|
/// Cosmetic room label (W7), chosen by the creator and carried in the ticket
|
|
/// so everyone who joins via it inherits the same label and reports "in
|
|
/// HangOut" in presence. Untrusted on receipt (it rides a peer-shared ticket)
|
|
/// — sanitize before display. `#[serde(default)]` keeps older, pre-label
|
|
/// tickets parseable (they decode to an empty label).
|
|
#[serde(default)]
|
|
pub name: String,
|
|
}
|
|
|
|
impl PeerSpeakTicket {
|
|
/// Member-issued ticket (W7 P3): re-stamp an existing ticket string with our
|
|
/// OWN address while keeping its room `topic_id` AND its cosmetic `name`, so
|
|
/// any member can hand out a working door that bootstraps off themselves — the
|
|
/// mechanism that lets a room outlive its creator. A no-op (returns the input
|
|
/// unchanged) if the string can't be parsed. Re-stamping with the same address
|
|
/// is idempotent.
|
|
pub fn restamp(ticket_str: &str, my_addr: iroh::EndpointAddr) -> String {
|
|
match ticket_str.parse::<PeerSpeakTicket>() {
|
|
Ok(t) => {
|
|
PeerSpeakTicket { host_addr: my_addr, topic_id: t.topic_id, name: t.name }
|
|
.to_string()
|
|
}
|
|
Err(_) => ticket_str.to_string(),
|
|
}
|
|
}
|
|
|
|
/// The cosmetic room label embedded in a ticket string, or `""` if the ticket
|
|
/// can't be parsed or carries no label. Pure; used to label the gathering both
|
|
/// in the room UI and in the presence we report to friends.
|
|
pub fn label_of(ticket_str: &str) -> String {
|
|
ticket_str.parse::<PeerSpeakTicket>().map(|t| t.name).unwrap_or_default()
|
|
}
|
|
|
|
/// The room's `topic_id` embedded in a ticket string, or `None` if the ticket
|
|
/// can't be parsed. Pure; used as the stable room identity for de-duplicating
|
|
/// the recents list (the host address and label change between members/sessions,
|
|
/// but the topic uniquely identifies the gathering).
|
|
pub fn topic_of(ticket_str: &str) -> Option<[u8; 32]> {
|
|
ticket_str.parse::<PeerSpeakTicket>().ok().map(|t| t.topic_id)
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for PeerSpeakTicket {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
// serde_json on a struct of String/[u8;32] fields is infallible in practice,
|
|
// but Display can't surface an error, so fall back to an empty ticket body.
|
|
let serialized = serde_json::to_vec(self).unwrap_or_default();
|
|
let encoded = base64::Engine::encode(
|
|
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
|
|
&serialized,
|
|
);
|
|
f.write_str(&encoded)
|
|
}
|
|
}
|
|
|
|
impl FromStr for PeerSpeakTicket {
|
|
type Err = NetError;
|
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
let decoded = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, s)
|
|
.map_err(|e| NetError::InvalidTicket(e.to_string()))?;
|
|
let ticket: PeerSpeakTicket = serde_json::from_slice(&decoded)
|
|
.map_err(|e| NetError::InvalidTicket(e.to_string()))?;
|
|
Ok(ticket)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait NetworkTransport: Send + Sync {
|
|
/// Establish (or ensure) a connection to a peer and set up its send path.
|
|
/// Idempotent; safe to call again for an already-connected peer — a repeat
|
|
/// call refreshes the retained dial address, so calling it again when a peer
|
|
/// re-announces (e.g. on a new address) keeps the dialer able to re-reach it.
|
|
/// The full `EndpointAddr` (relay + direct addrs) is retained so reconnects
|
|
/// dial it directly instead of depending on a lookup that a transient gossip
|
|
/// `Leave`/`NeighborDown` may have purged.
|
|
async fn connect_peer(&self, addr: iroh::EndpointAddr);
|
|
|
|
/// Tear down the connection and send path for a peer that has left.
|
|
async fn disconnect_peer(&self, peer_id: EndpointId);
|
|
|
|
/// Fan a single audio datagram out to every connected peer. Non-blocking:
|
|
/// per-peer queues drop the oldest-pending frame when full, so a slow link
|
|
/// can never stall the capture/encode thread. Callable from any thread.
|
|
fn broadcast(&self, data: Bytes);
|
|
|
|
/// Subscribes to incoming datagrams from any peer.
|
|
async fn receive_datagrams(&self) -> Result<Receiver<(EndpointId, Bytes)>, NetError>;
|
|
|
|
/// Subscribes to per-peer link-state changes (connecting / connected).
|
|
async fn subscribe_conn_events(&self) -> Result<Receiver<ConnEvent>, NetError>;
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait RoomState: Send + Sync {
|
|
/// Joins a room using a gossip ticket string and announces our state.
|
|
///
|
|
/// `extra_bootstrap` are additional peer addresses to dial when entering the
|
|
/// gossip swarm, on top of the ticket's host. This is what lets the ROOM
|
|
/// CREATOR rejoin a room they left: their own ticket lists only themselves as
|
|
/// host, so without retained peers they'd have no dial target and never
|
|
/// re-enter the swarm (bug A8). Callers with no prior peers pass `vec![]`.
|
|
async fn join(
|
|
&self,
|
|
ticket: &str,
|
|
self_state: PeerState,
|
|
extra_bootstrap: Vec<EndpointAddr>,
|
|
) -> Result<(), NetError>;
|
|
|
|
/// Updates our local state (e.g. when user mutes/unmutes) and broadcasts it.
|
|
async fn update_self_state(&self, self_state: PeerState) -> Result<(), NetError>;
|
|
|
|
/// Ask the active gossip topic to connect to retained peer addresses without
|
|
/// leaving or replacing the subscription. This is a recovery primitive only:
|
|
/// it does not add peers to the authenticated room roster. A peer becomes
|
|
/// active only after its normal signed `Announce` is received and verified.
|
|
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), NetError>;
|
|
|
|
/// Remove a peer from the authenticated live roster before background
|
|
/// recovery. This only revokes membership; a fresh verified `Announce` is
|
|
/// required to add the peer again.
|
|
fn mark_peer_disconnected(&self, peer_id: EndpointId);
|
|
|
|
/// Broadcasts a room text-chat message authored by us (our display name is
|
|
/// taken from the current self-state), optionally carrying a file attachment
|
|
/// descriptor whose bytes are served separately on the file plane.
|
|
async fn send_chat(
|
|
&self,
|
|
text: String,
|
|
attachment: Option<crate::files::ChatAttachment>,
|
|
) -> Result<(), NetError>;
|
|
|
|
/// Leaves the room and announces departure.
|
|
async fn leave(&self) -> Result<(), NetError>;
|
|
|
|
/// Returns a list of currently active peers in the room.
|
|
fn active_peers(&self) -> Vec<(EndpointId, PeerState)>;
|
|
|
|
/// Subscribes to room events (peer joined, peer left, peer updated).
|
|
async fn subscribe_events(&self) -> Result<Receiver<RoomEvent>, NetError>;
|
|
}
|
|
|
|
pub mod iroh_impl;
|
|
pub mod gossip;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use iroh::{SecretKey, EndpointAddr};
|
|
|
|
fn sample_peer_state() -> PeerState {
|
|
let secret = SecretKey::generate();
|
|
let public = secret.public();
|
|
let addr = EndpointAddr::from(public);
|
|
PeerState {
|
|
name: "TestPeer".to_string(),
|
|
is_muted: false,
|
|
addr,
|
|
sharing: None,
|
|
avatar: crate::avatar::Avatar::default(),
|
|
game: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_ticket_round_trip() {
|
|
let state = sample_peer_state();
|
|
let topic_id = [7u8; 32];
|
|
let original_ticket = PeerSpeakTicket {
|
|
host_addr: state.addr.clone(),
|
|
topic_id,
|
|
name: "HangOut".to_string(),
|
|
};
|
|
let ticket_str = original_ticket.to_string();
|
|
let parsed_ticket = ticket_str.parse::<PeerSpeakTicket>().unwrap();
|
|
assert_eq!(parsed_ticket.host_addr.id, original_ticket.host_addr.id);
|
|
assert_eq!(parsed_ticket.topic_id, original_ticket.topic_id);
|
|
assert_eq!(parsed_ticket.name, "HangOut");
|
|
}
|
|
|
|
#[test]
|
|
fn test_ticket_label_helpers_and_backcompat() {
|
|
let host = SecretKey::generate().public();
|
|
let topic_id = [3u8; 32];
|
|
// A labelled ticket: restamp keeps the label, label_of reads it.
|
|
let labelled =
|
|
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id, name: "HangOut".into() }
|
|
.to_string();
|
|
assert_eq!(PeerSpeakTicket::label_of(&labelled), "HangOut");
|
|
let member = SecretKey::generate().public();
|
|
let restamped = PeerSpeakTicket::restamp(&labelled, EndpointAddr::from(member));
|
|
assert_eq!(PeerSpeakTicket::label_of(&restamped), "HangOut");
|
|
// An unparseable ticket has no label rather than panicking.
|
|
assert_eq!(PeerSpeakTicket::label_of("not-a-ticket"), "");
|
|
// topic_of reads the room identity, and returns None for a bad ticket.
|
|
assert_eq!(PeerSpeakTicket::topic_of(&labelled), Some(topic_id));
|
|
assert_eq!(PeerSpeakTicket::topic_of(&restamped), Some(topic_id));
|
|
assert_eq!(PeerSpeakTicket::topic_of("not-a-ticket"), None);
|
|
// Backward-compat: a pre-label ticket JSON (no `name` key) still parses,
|
|
// defaulting the label to "".
|
|
let legacy_json = serde_json::json!({
|
|
"host_addr": serde_json::to_value(EndpointAddr::from(host)).unwrap(),
|
|
"topic_id": topic_id.to_vec(),
|
|
});
|
|
let legacy_str = base64::Engine::encode(
|
|
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
|
|
serde_json::to_vec(&legacy_json).unwrap(),
|
|
);
|
|
let parsed = legacy_str.parse::<PeerSpeakTicket>().unwrap();
|
|
assert_eq!(parsed.name, "");
|
|
}
|
|
|
|
#[test]
|
|
fn test_malformed_rejection() {
|
|
// empty string
|
|
let res1 = "".parse::<PeerSpeakTicket>();
|
|
assert!(matches!(res1, Err(NetError::InvalidTicket(_))));
|
|
|
|
// non-base64 garbage
|
|
let res2 = "!!!not base64!!!".parse::<PeerSpeakTicket>();
|
|
assert!(matches!(res2, Err(NetError::InvalidTicket(_))));
|
|
|
|
// valid URL-safe-base64 that decodes to non-JSON bytes
|
|
let bad_json = b"hello world";
|
|
let encoded = base64::Engine::encode(
|
|
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
|
|
bad_json,
|
|
);
|
|
let res3 = encoded.parse::<PeerSpeakTicket>();
|
|
assert!(matches!(res3, Err(NetError::InvalidTicket(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn test_restamp_swaps_addr_keeps_topic() {
|
|
// A ticket "from" the host, then re-stamped by another member.
|
|
let host = SecretKey::generate().public();
|
|
let member = SecretKey::generate().public();
|
|
let topic_id = [42u8; 32];
|
|
let original =
|
|
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id, name: "HangOut".into() }
|
|
.to_string();
|
|
|
|
let restamped_str = PeerSpeakTicket::restamp(&original, EndpointAddr::from(member));
|
|
let restamped = restamped_str.parse::<PeerSpeakTicket>().unwrap();
|
|
// Same room (and label), but the door now points at the member, not the host.
|
|
assert_eq!(restamped.topic_id, topic_id);
|
|
assert_eq!(restamped.host_addr.id, member);
|
|
assert_ne!(restamped.host_addr.id, host);
|
|
assert_eq!(restamped.name, "HangOut");
|
|
}
|
|
|
|
#[test]
|
|
fn test_restamp_is_idempotent_for_same_addr() {
|
|
let me = SecretKey::generate().public();
|
|
let topic_id = [7u8; 32];
|
|
let mine =
|
|
PeerSpeakTicket { host_addr: EndpointAddr::from(me), topic_id, name: String::new() }
|
|
.to_string();
|
|
// Re-stamping my own ticket with my own addr changes nothing.
|
|
assert_eq!(PeerSpeakTicket::restamp(&mine, EndpointAddr::from(me)), mine);
|
|
}
|
|
|
|
#[test]
|
|
fn test_restamp_passes_through_unparseable() {
|
|
let me = SecretKey::generate().public();
|
|
// A malformed ticket is returned unchanged (the join will fail anyway).
|
|
assert_eq!(PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)), "not-a-ticket");
|
|
}
|
|
|
|
#[test]
|
|
fn self_presence_builds_peer_state_with_volatile_fields() {
|
|
let addr = EndpointAddr::from(SecretKey::generate().public());
|
|
let presence = SelfPresence {
|
|
name: "Alice".to_string(),
|
|
avatar: crate::avatar::Avatar::default(),
|
|
game: Some("Half-Life 2".to_string()),
|
|
};
|
|
// Volatile fields come from the call; sticky fields from the struct.
|
|
let muted = presence.to_state(true, addr.clone(), Some("ticket".to_string()));
|
|
assert_eq!(muted.name, "Alice");
|
|
assert!(muted.is_muted);
|
|
assert_eq!(muted.addr.id, addr.id);
|
|
assert_eq!(muted.sharing.as_deref(), Some("ticket"));
|
|
assert_eq!(muted.avatar, crate::avatar::Avatar::default());
|
|
assert_eq!(muted.game.as_deref(), Some("Half-Life 2"));
|
|
// The same sticky presence yields different volatile fields per announce.
|
|
let unmuted = presence.to_state(false, addr.clone(), None);
|
|
assert!(!unmuted.is_muted);
|
|
assert_eq!(unmuted.sharing, None);
|
|
assert_eq!(unmuted.name, muted.name);
|
|
}
|
|
|
|
#[test]
|
|
fn test_peer_state_serde_round_trip() {
|
|
let original = sample_peer_state();
|
|
let serialized = serde_json::to_string(&original).unwrap();
|
|
let deserialized: PeerState = serde_json::from_str(&serialized).unwrap();
|
|
assert_eq!(original, deserialized);
|
|
}
|
|
}
|