diff --git a/crates/lanspread-peer/ARCHITECTURE.md b/crates/lanspread-peer/ARCHITECTURE.md index 923256a..ad88df3 100644 --- a/crates/lanspread-peer/ARCHITECTURE.md +++ b/crates/lanspread-peer/ARCHITECTURE.md @@ -75,7 +75,12 @@ When a peer is discovered: Call-to-Play domains independently. Commit the endpoint and each valid domain only if the candidate lease is still current for both peer ID and address. This domain isolation lets an invalid remote Call-to-Play slice be cleared or - preserved according to its session without discarding a valid library. + preserved according to its session without discarding a valid library. After + the pinned Hello succeeds, at most eight committed identities may reside at + one stored endpoint IP, alongside the global 64-peer limit. This + authenticated residency bound is separate from the pre-authentication mDNS + candidate quota; a ninth identity is rejected without evicting an existing + address owner, and removing a peer releases its slot. 5. Assign a fresh endpoint generation to every successful authentication. Dropped, failed, or superseded work releases its candidate claims, and a late result cannot mutate a newer generation. diff --git a/crates/lanspread-peer/src/peer_db.rs b/crates/lanspread-peer/src/peer_db.rs index ad22bfc..a7c76a2 100644 --- a/crates/lanspread-peer/src/peer_db.rs +++ b/crates/lanspread-peer/src/peer_db.rs @@ -20,6 +20,7 @@ use lanspread_proto::{ use tokio::time::Instant; pub const MAX_AUTHENTICATED_PEERS: usize = 64; +const MAX_AUTHENTICATED_PEERS_PER_ENDPOINT_IP: usize = 8; const MAX_TOTAL_REMOTE_LIBRARY_GAMES: usize = 16_384; /// Monotonic identity for one successfully authenticated endpoint observation. @@ -362,6 +363,20 @@ impl PeerGameDB { let existing_at_addr = self.addr_index.get(&endpoint.addr).copied(); let is_new = !self.peers.contains_key(&endpoint.peer_id); let makes_room = existing_at_addr.is_some_and(|id| id != endpoint.peer_id); + // This endpoint has completed the identity-pinned Hello exchange. Count + // the IP that will be stored in `PeerInfo`, independently of the + // pre-authentication mDNS candidate quota. + let endpoint_ip_occupancy = self + .peers + .values() + .filter(|peer| peer.peer_id != endpoint.peer_id && peer.addr.ip() == endpoint.addr.ip()) + .count(); + if endpoint_ip_occupancy >= MAX_AUTHENTICATED_PEERS_PER_ENDPOINT_IP { + eyre::bail!( + "authenticated peer limit of {MAX_AUTHENTICATED_PEERS_PER_ENDPOINT_IP} reached for endpoint IP {}", + endpoint.addr.ip() + ); + } if is_new && self.peers.len() >= MAX_AUTHENTICATED_PEERS && !makes_room { eyre::bail!("authenticated peer limit of {MAX_AUTHENTICATED_PEERS} reached"); } @@ -735,7 +750,11 @@ mod tests { use super::*; fn addr(port: u16) -> SocketAddr { - SocketAddr::from(([127, 0, 0, 1], port)) + addr_at([127, 0, 0, 1], port) + } + + fn addr_at(ip: [u8; 4], port: u16) -> SocketAddr { + SocketAddr::from((ip, port)) } fn peer_id(seed: u8) -> PeerId { @@ -746,6 +765,10 @@ mod tests { PeerEndpoint::new(peer_id(seed), addr(port)) } + fn endpoint_at(seed: u8, ip: [u8; 4], port: u16) -> PeerEndpoint { + PeerEndpoint::new(peer_id(seed), addr_at(ip, port)) + } + fn session(seed: u8) -> RuntimeSessionId { RuntimeSessionId::from_bytes([seed; 16]) } @@ -815,6 +838,104 @@ mod tests { assert_eq!(db.peer_endpoint(&endpoint.peer_id), Some(endpoint)); } + #[test] + fn endpoint_ip_residency_allows_same_identity_port_rotation_but_rejects_a_ninth_key() { + const ORIGIN: [u8; 4] = [192, 0, 2, 10]; + let mut db = PeerGameDB::new(); + let mut admitted = Vec::new(); + for seed in 1..=u8::try_from(MAX_AUTHENTICATED_PEERS_PER_ENDPOINT_IP) + .expect("endpoint IP limit fits in a test seed") + { + let endpoint = endpoint_at(seed, ORIGIN, 12_000 + u16::from(seed)); + commit(&mut db, endpoint, session(seed), library(0, None), Some(0)); + admitted.push(endpoint); + } + + let moved = endpoint_at(1, ORIGIN, 13_001); + let moved_upsert = commit(&mut db, moved, session(1), library(0, None), Some(0)); + assert!(moved_upsert.addr_changed); + assert_eq!(db.peer_endpoint(&moved.peer_id), Some(moved)); + + let rotated_key = endpoint_at(9, ORIGIN, 13_009); + let ticket = db + .begin_candidate_negotiation(rotated_key) + .expect("ninth key should reach authenticated commit admission"); + let error = db + .commit_authenticated_snapshot(rotated_key, ticket, session(9), Some(library(0, None))) + .expect_err("a ninth identity at one authenticated endpoint IP must be rejected"); + assert!( + error + .to_string() + .contains("authenticated peer limit of 8 reached for endpoint IP 192.0.2.10"), + "unexpected endpoint IP admission error: {error:#}" + ); + assert!(!db.contains_peer(&rotated_key.peer_id)); + assert_eq!( + db.peer_endpoints().len(), + MAX_AUTHENTICATED_PEERS_PER_ENDPOINT_IP + ); + assert!( + admitted + .iter() + .skip(1) + .all(|endpoint| db.peer_endpoint(&endpoint.peer_id) == Some(*endpoint)), + "rejecting the rotated key must preserve every existing identity" + ); + } + + #[test] + fn endpoint_ip_residency_rejects_before_address_eviction_and_releases_on_removal() { + const CROWDED_ORIGIN: [u8; 4] = [192, 0, 2, 20]; + const OTHER_ORIGIN: [u8; 4] = [192, 0, 2, 21]; + let mut db = PeerGameDB::new(); + let crowded = (1..=u8::try_from(MAX_AUTHENTICATED_PEERS_PER_ENDPOINT_IP) + .expect("endpoint IP limit fits in a test seed")) + .map(|seed| endpoint_at(seed, CROWDED_ORIGIN, 14_000 + u16::from(seed))) + .collect::>(); + for endpoint in &crowded { + let seed = endpoint.peer_id.as_bytes()[0]; + commit(&mut db, *endpoint, session(seed), library(0, None), Some(0)); + } + + let address_owner = crowded[0]; + let colliding_key = PeerEndpoint::new(peer_id(9), address_owner.addr); + let ticket = db + .begin_candidate_negotiation(colliding_key) + .expect("colliding key should reach authenticated commit admission"); + assert!( + db.commit_authenticated_snapshot( + colliding_key, + ticket, + session(9), + Some(library(0, None)), + ) + .is_err(), + "the endpoint IP bound must run before same-address eviction" + ); + assert_eq!( + db.peer_endpoint(&address_owner.peer_id), + Some(address_owner), + "the ninth identity must not evict the authenticated address owner" + ); + + let other_origin = endpoint_at(9, OTHER_ORIGIN, 14_009); + commit(&mut db, other_origin, session(9), library(0, None), Some(0)); + assert_eq!(db.peer_endpoint(&other_origin.peer_id), Some(other_origin)); + + let removed = crowded[1]; + assert!(db.remove_peer(&removed.peer_id).is_some()); + let replacement = endpoint_at(10, CROWDED_ORIGIN, 14_010); + commit(&mut db, replacement, session(10), library(0, None), Some(0)); + assert_eq!(db.peer_endpoint(&replacement.peer_id), Some(replacement)); + assert_eq!( + db.peer_endpoints() + .into_iter() + .filter(|endpoint| endpoint.addr.ip() == address_owner.addr.ip()) + .count(), + MAX_AUTHENTICATED_PEERS_PER_ENDPOINT_IP + ); + } + #[test] fn aggregate_remote_library_budget_drops_only_the_oversized_projection() { let mut db = PeerGameDB::new();