Files
peerspeak/src/network/mod.rs
T
molluskandClaude Opus 4.8 e3ff778d5b
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
A25: surface a clock-skew warning instead of failing silently
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>
2026-06-27 03:43:39 -04:00

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);
}
}