feat: v0.8 Iteration C — narrow mesh, N1-N4 uniques index, anchor convection

Topology rework. The mesh's job is width; depth now comes from knowledge.

Slots:
- Local/Wide collapse into ONE mesh pool: 20 desktop / 15 mobile
- Temp referral slots +4..+10 ABOVE the cap (10 desktop / 4 mobile), carry no
  knowledge exchange, graduate into a freed mesh slot or expire (5 min TTL),
  never evict an established mesh peer
- Fixes the Wide-never-fills bug: outbound paths hardcoded Local while the
  growth gate counted Local only, so growth stopped at 71 and 20 slots were
  inbound-only. Also closes register_connection paths that enforced no cap.

Knowledge — two-pool uniques announce (replaces N1/N2/N3 share lists):
- Pool 1 (forwardable): our N0-N2 uniques, deduped. Receiver stores shifted one
  bounce deeper as N1-N3, reporter-tagged.
- Pool 2 (terminal): our N3, deduped against pool 1. Receiver stores as N4,
  USED for search/resolution but NEVER re-announced. No N5 anywhere.
- Uniques merge mesh peers + social directs + CDN file authors + holder peers,
  so receivers cannot tell how we know an ID
- Anchor entries carry addresses; every other entry is a bare ID. The pools
  double as the anchor directory.
- Dedup at both store and announce; per-bounce caps; 2 MB payload ceiling
- Device-tiered depth: mobile holds 3 bounces and drops the terminal pool

Anchor convection (register loop retired — 0xC0 AnchorRegister deleted):
- Rolling in-memory caller window (no registration DB): a caller gets the 2
  most recent viable prior callers, its address goes to the next 2, then
  rotates out; still-connected callers preferred; RelayIntroduce coordinated
  when both ends are live
- Request classes: entry (<2 connections) always served; top-up refused
  cheaply under load, and the refusal feeds the adaptive weights
- Stochastic per-disconnect action {nothing | anchor intro | mesh intro} with
  weights from local anchor density + refusal feedback. No thresholds, no
  jitter timer. Recovery (mesh < 2) stays immediate and non-stochastic.
- Anchors mined from the uniques pools incl. retained pools of disconnected
  peers; known_anchors demoted to a bootstrap cache

Security fixes from review: remote address poisoning (hearsay anchors could be
dialled — known_anchors/is_anchor now written only from completed handshakes),
N4 leaking back into announcements via the anchor mirror (infinite propagation),
missing payload size caps, unbounded candidate scoring, and a bulk SQLite write
held under the ConnectionManager mutex.

deploy.sh: version regex now captures pre-release suffixes (0.8.0-alpha would
have truncated to 0.8.0 and broken every artifact path), announce channel
derives from the suffix, and the anchor runs --publish-registry at genesis.

228 core tests; a3 integration 9/9; new c_topology_test.sh 33/33 (both
independently re-run). EDM corpse, session-relay gating, Iteration A/B intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
This commit is contained in:
Scott Reimers 2026-07-30 13:03:12 -04:00
parent 36e3871c4b
commit e756fabb11
14 changed files with 5503 additions and 2367 deletions

File diff suppressed because it is too large Load diff

View file

@ -14,7 +14,7 @@ use crate::content::compute_post_id;
use crate::crypto;
use crate::storage::Storage;
use crate::types::{
GroupKeyDistributionContent, GroupKeyRecord, GroupMemberKey, NodeId, Post, PostId,
GroupKeyDistributionContent, GroupKeyRecord, NodeId, Post, PostId,
PostVisibility, PostingIdentity, VisibilityIntent,
};

View file

@ -10,16 +10,15 @@ use tracing::{debug, error, info, warn};
use crate::activity::{ActivityCategory, ActivityLevel, ActivityLog};
use crate::blob::BlobStore;
use crate::connection::{initial_exchange_accept, initial_exchange_connect, ConnHandle, ConnectionActor, ConnectionManager, ExchangeResult};
use crate::content::verify_post_id;
use crate::protocol::{
read_message_type, read_payload, write_typed_message, BlobRequestPayload, BlobResponsePayload,
MessageType, ProfileUpdatePayload,
PullSyncRequestPayload, PullSyncResponsePayload, RefuseRedirectPayload,
RefuseRedirectPayload,
ALPN,
};
use crate::storage::StoragePool;
use crate::types::{
DeviceProfile, DeviceRole, NodeId, PeerSlotKind, Post, PostId,
DeviceProfile, DeviceRole, NodeId, MeshSlot, Post, PostId,
PostVisibility, PublicProfile, SessionReachMethod, WormResult,
};
@ -734,13 +733,16 @@ impl Network {
recv: iroh::endpoint::RecvStream,
is_mesh: &AtomicBool,
) -> bool {
// Try to allocate a slot
let accepted = conn_handle.accept_connection(
// Try to allocate a slot. `None` = both the mesh pool and the temp
// referral band are full.
let slot = conn_handle.accept_connection(
conn.clone(), remote_node_id, Some(remote_sock),
).await;
info!(peer = hex::encode(remote_node_id), accepted, "try_mesh_upgrade: accept_connection result");
if !accepted {
info!(peer = hex::encode(remote_node_id), accepted = slot.is_some(), "try_mesh_upgrade: accept_connection result");
let slot = match slot {
Some(s) => s,
None => {
let redirect = conn_handle.pick_random_redirect_peer(&remote_node_id).await;
let payload = RefuseRedirectPayload {
reason: "slots full".to_string(),
@ -752,13 +754,19 @@ impl Network {
debug!(peer = hex::encode(remote_node_id), "Refused mesh connection (slots full)");
conn_handle.add_session(remote_node_id, conn.clone(), crate::types::SessionReachMethod::Direct, Some(remote_sock)).await;
conn_handle.log_activity(ActivityLevel::Info, ActivityCategory::Connection, format!("Refused {} mesh (slots full), added session", &hex::encode(remote_node_id)[..8]), Some(remote_node_id));
return false;
}
return false;
}
};
{
let s = storage.get().await;
// bugs-fixed #2: address always persisted, temp slots included.
let _ = s.upsert_peer(&remote_node_id, &[remote_sock], None);
let _ = s.add_mesh_peer(&remote_node_id, PeerSlotKind::Local, 0);
// But mesh_peers ONLY for a real mesh slot — that omission is what
// keeps temp referral peers out of our uniques announce.
if slot.is_mesh() {
let _ = s.add_mesh_peer(&remote_node_id);
}
if s.has_social_route(&remote_node_id).unwrap_or(false) {
let _ = s.touch_social_route_connect(
&remote_node_id,
@ -774,9 +782,10 @@ impl Network {
let our_nat_type = conn_handle.nat_type().await;
let our_http_capable = conn_handle.is_http_capable();
let our_http_addr = conn_handle.http_addr();
match initial_exchange_accept(storage, &our_node_id, send, recv, remote_node_id, anchor_addr, Some(remote_sock), our_nat_type, our_http_capable, our_http_addr, conn_handle.device_role(), None, false).await {
let our_depth = conn_handle.max_bounce();
match initial_exchange_accept(storage, &our_node_id, send, recv, remote_node_id, anchor_addr, Some(remote_sock), our_nat_type, our_http_capable, our_http_addr, conn_handle.device_role(), None, false, our_depth, slot.is_mesh()).await {
Ok(()) => {
info!(peer = hex::encode(remote_node_id), "Initial exchange complete (upgraded to mesh)");
info!(peer = hex::encode(remote_node_id), slot = %slot, "Initial exchange complete (upgraded to mesh)");
conn_handle.log_activity(ActivityLevel::Info, ActivityCategory::Connection, format!("Upgraded {} to mesh", &hex::encode(remote_node_id)[..8]), Some(remote_node_id));
}
Err(e) => {
@ -819,12 +828,15 @@ impl Network {
let conn = ConnectionManager::connect_to_unlocked(&self.endpoint, addr).await?;
// Register the established connection
self.conn_handle.register_connection(peer_id, conn.clone(), addrs, PeerSlotKind::Local).await;
let slot = match self.conn_handle.register_connection(peer_id, conn.clone(), addrs).await {
Some(s) => s,
None => anyhow::bail!("no slot available (mesh + temp full)"),
};
let anchor_addr = self.conn_handle.build_anchor_advertised_addr().await;
let our_nat_type = self.conn_handle.nat_type().await;
// Initial exchange WITHOUT holding conn_mgr lock
match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, peer_id, anchor_addr, our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None).await? {
match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, peer_id, anchor_addr, our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None, self.conn_handle.max_bounce(), slot.is_mesh()).await? {
ExchangeResult::Accepted { duplicate_active } => {
if duplicate_active {
self.duplicate_detected.store(true, std::sync::atomic::Ordering::Relaxed);
@ -870,27 +882,38 @@ impl Network {
}
}
/// Pull from all connected peers.
pub async fn pull_from_all(&self) -> anyhow::Result<PullStats> {
/// TRANSITIONAL content sync from every connected peer, plus the
/// engagement fetch that Iteration A's registry and greetings ride on.
///
/// Routes through the actor path (`content_sync_unlocked`), NOT a second
/// local implementation: the old `Network::pull_from_peer` stored posts
/// with `store_post_with_visibility` instead of `control::receive_post`, so
/// it silently dropped `intent` (control posts — deletes, revocations, key
/// distributions — were never processed) and never called
/// `touch_file_holder`, which is why posts received on the startup pull
/// registered no CDN holder.
pub async fn content_sync_all(&self) -> anyhow::Result<PullStats> {
let peers = self.conn_handle.connected_peers().await;
let mut total_posts = 0;
let mut success = 0;
for peer_id in peers {
// Uses Network::pull_from_peer which doesn't hold conn_mgr lock during I/O
let result = self.pull_from_peer(&peer_id).await;
let result = self.conn_handle.content_sync_from_peer(&peer_id).await;
match result {
Ok(stats) => {
total_posts += stats.posts_received;
success += 1;
// Also fetch engagement data
// Engagement fetch is what carries Iteration A's registry
// entries and greeting comments (BlobHeaderRequest/Response
// + BlobHeaderDiff). It MUST stay driven on a timer — this
// is the pull side of that safety net.
let _ = self.conn_handle.fetch_engagement_from_peer(&peer_id).await;
if stats.posts_received > 0 {
info!(
peer = hex::encode(peer_id),
posts = stats.posts_received,
"Pulled posts"
"Content sync: received posts"
);
}
}
@ -898,7 +921,7 @@ impl Network {
debug!(
peer = hex::encode(peer_id),
error = %e,
"Pull failed"
"Content sync failed"
);
}
}
@ -910,82 +933,85 @@ impl Network {
})
}
/// Broadcast routing diff to all connected peers.
/// Uses ConnHandle to get diff data, then sends outside the lock.
pub async fn broadcast_diff(&self) -> anyhow::Result<usize> {
use crate::protocol::{NodeListUpdatePayload, write_typed_message, MessageType};
let snapshot = self.conn_handle.get_diff_data().await;
if snapshot.n1_added.is_empty() && snapshot.n1_removed.is_empty()
&& snapshot.n2_added.is_empty() && snapshot.n2_removed.is_empty()
{
return Ok(0);
/// Run the uniques-index exchange (0x40/0x41) against every MESH peer.
///
/// This is what a "pull" means in v0.8: an exchange of the IDs each side
/// has a live path to. Symmetric, so one round trip refreshes both indexes.
/// Temp referral slots are skipped — they carry no knowledge.
pub async fn uniques_pull_all(&self) -> usize {
let conns = self.conn_handle.get_connection_map().await;
let mut exchanged = 0;
for (peer_id, conn, slot, _) in conns {
if !slot.is_mesh() {
continue;
}
let fut = crate::connection::ConnectionManager::uniques_pull_unlocked(
&self.conn_mgr,
conn,
&peer_id,
);
match tokio::time::timeout(std::time::Duration::from_secs(20), fut).await {
Ok(Ok(n)) => {
exchanged += 1;
if n > 0 {
debug!(peer = hex::encode(peer_id), rows = n, "Uniques pull: index updated");
}
}
Ok(Err(e)) => debug!(peer = hex::encode(peer_id), error = %e, "Uniques pull failed"),
Err(_) => debug!(peer = hex::encode(peer_id), "Uniques pull timed out"),
}
}
exchanged
}
let payload = NodeListUpdatePayload {
seq: snapshot.diff_seq,
n1_added: snapshot.n1_added,
n1_removed: snapshot.n1_removed,
n2_added: snapshot.n2_added,
n2_removed: snapshot.n2_removed,
/// Broadcast our uniques announce to every MESH peer.
///
/// Temporary referral slots are excluded — they carry no knowledge
/// exchange. Peers that advertised `depth < 4` get the terminal pool
/// stripped before send (the biggest saving on a cellular link).
///
/// The announce is always a full snapshot; `snapshot.unchanged` is what
/// stands in for incremental diffing, so an idle mesh sends nothing.
pub async fn broadcast_uniques(&self) -> anyhow::Result<usize> {
use crate::protocol::{write_typed_message, MessageType, UniquesSlice};
let snapshot = self.conn_handle.get_announce_data().await;
let payload = match snapshot.payload {
Some(p) if !snapshot.unchanged => p,
_ => return Ok(0),
};
let mut sent = 0;
for (peer_id, conn) in &snapshot.connections {
for (peer_id, conn, peer_depth) in &snapshot.connections {
let per_peer = crate::protocol::UniquesAnnouncePayload {
seq: payload.seq,
full: payload.full,
fwd: payload.fwd.clone(),
term: if *peer_depth >= 4 { payload.term.clone() } else { UniquesSlice::default() },
depth: payload.depth,
};
let result = async {
let mut send = conn.open_uni().await?;
write_typed_message(&mut send, MessageType::NodeListUpdate, &payload).await?;
write_typed_message(&mut send, MessageType::UniquesAnnounce, &per_peer).await?;
send.finish()?;
anyhow::Ok(())
}.await;
if result.is_ok() {
sent += 1;
} else {
debug!(peer = hex::encode(peer_id), "Failed to send routing diff");
debug!(peer = hex::encode(peer_id), "Failed to send uniques announce");
}
}
Ok(sent)
}
/// Broadcast full N1/N2 state to all mesh peers (periodic catch-up for missed diffs).
/// Periodic catch-up: force the next announce even if the pools are
/// unchanged, so a peer that missed one resynchronises.
/// (The old `broadcast_full_state` is subsumed — every announce is already
/// a full snapshot.)
pub async fn broadcast_full_state(&self) -> anyhow::Result<usize> {
use crate::protocol::{NodeListUpdatePayload, write_typed_message, MessageType};
let snapshot = self.conn_handle.get_diff_data().await;
// Build full state: all current N1 and N2 as "added", nothing removed
let all_n1 = self.conn_handle.connected_peers().await;
let all_n2 = {
let storage = self.storage.get().await;
storage.build_n2_share().unwrap_or_default()
};
if all_n1.is_empty() && all_n2.is_empty() {
return Ok(0);
}
let payload = NodeListUpdatePayload {
seq: snapshot.diff_seq,
n1_added: all_n1,
n1_removed: vec![],
n2_added: all_n2,
n2_removed: vec![],
};
let mut sent = 0;
for (_peer_id, conn) in &snapshot.connections {
let result = async {
let mut send = conn.open_uni().await?;
write_typed_message(&mut send, MessageType::NodeListUpdate, &payload).await?;
send.finish()?;
anyhow::Ok(())
}.await;
if result.is_ok() {
sent += 1;
}
}
Ok(sent)
self.conn_handle.force_announce().await;
self.broadcast_uniques().await
}
/// Push a profile update to all audience members (ephemeral-capable).
@ -1191,7 +1217,7 @@ impl Network {
}
/// Get connection info for display.
pub async fn connection_info(&self) -> Vec<(NodeId, PeerSlotKind, u64)> {
pub async fn connection_info(&self) -> Vec<(NodeId, MeshSlot, u64)> {
self.conn_handle.connection_info().await
}
@ -1226,11 +1252,15 @@ impl Network {
}
match self.connect_to_peer(peer_id, addr.clone()).await {
Ok(()) => Ok(()),
Ok(()) => {
self.promote_proven_anchor(peer_id, &addr).await;
Ok(())
}
Err(e) if e.to_string().contains("mesh refused") => {
// Anchor refused mesh — reconnect as session for registration
let conn = ConnectionManager::connect_to_unlocked(&self.endpoint, addr).await?;
let conn = ConnectionManager::connect_to_unlocked(&self.endpoint, addr.clone()).await?;
self.conn_handle.add_session(peer_id, conn, crate::types::SessionReachMethod::Direct, None).await;
self.promote_proven_anchor(peer_id, &addr).await;
self.conn_handle.log_activity(
ActivityLevel::Info,
ActivityCategory::Anchor,
@ -1243,6 +1273,28 @@ impl Network {
}
}
/// An anchor we have COMPLETED a QUIC handshake to. iroh has proven the
/// node ID owns this path, so — and only so — the entry may enter the
/// authoritative address state: `peers.is_anchor` and the `known_anchors`
/// bootstrap cache.
///
/// This is the counterpart to removing the announce-path mirror. Hearsay
/// anchor claims live in `reachable` only (mineable via `list_pool_anchors`,
/// which is what "the pools ARE the anchor directory" means); proven ones
/// live here, which is also what makes `known_anchors.last_seen_ms`
/// ordering meaningful again — and keeps the DNS-refreshed bootstrap anchor
/// from being evicted by a flood of gossip-sourced entries.
async fn promote_proven_anchor(&self, peer_id: NodeId, addr: &iroh::EndpointAddr) {
let socks: Vec<std::net::SocketAddr> = addr.ip_addrs().copied().collect();
if socks.is_empty() {
return;
}
let storage = self.storage.get().await;
let _ = storage.upsert_peer(&peer_id, &socks, None);
let _ = storage.set_peer_anchor(&peer_id, true);
let _ = storage.upsert_known_anchor(&peer_id, &socks);
}
/// Register an already-established QUIC connection as a mesh peer.
/// Sends InitialExchange first — if the remote accepts (responds with
/// InitialExchange), registers as mesh and spawns the stream loop.
@ -1259,13 +1311,15 @@ impl Network {
let anchor_addr = self.conn_handle.build_anchor_advertised_addr().await;
let our_nat_type = self.conn_handle.nat_type().await;
match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, peer_id, anchor_addr, our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None).await {
// Decide the slot BEFORE the exchange: the slot class is what says
// whether this handshake carries a uniques announce.
// bugs-fixed #3: a connection is registered before anything can drop it.
let slot = match self.conn_handle.register_connection(peer_id, conn.clone(), vec![]).await {
Some(s) => s,
None => anyhow::bail!("no slot available (mesh + temp full)"),
};
match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, peer_id, anchor_addr, our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None, self.conn_handle.max_bounce(), slot.is_mesh()).await {
Ok(ExchangeResult::Accepted { .. }) => {
self.conn_handle.register_connection(peer_id, conn.clone(), vec![], PeerSlotKind::Local).await;
{
let s = self.storage.get().await;
let _ = s.add_mesh_peer(&peer_id, PeerSlotKind::Local, 0);
}
// Spawn the per-connection stream loop
let conn_data = self.conn_handle.get_connection_map().await;
@ -1280,10 +1334,16 @@ impl Network {
Ok(ExchangeResult::Refused { redirect }) => {
let redir_info = redirect.as_ref().map(|r| r.n.clone());
info!(peer = hex::encode(peer_id), redirect = ?redir_info, "Mesh refused after hole punch, keeping as session");
self.conn_handle.disconnect_peer(&peer_id).await;
self.conn_handle.add_session(peer_id, conn, crate::types::SessionReachMethod::HolePunch, None).await;
anyhow::bail!("mesh refused: slots full");
}
Err(e) => Err(e),
Err(e) => {
// Roll back the slot we reserved — otherwise a failed handshake
// holds a mesh slot until the zombie sweep.
self.conn_handle.disconnect_peer(&peer_id).await;
Err(e)
}
}
}
@ -1344,7 +1404,9 @@ impl Network {
None,
);
if remaining == 0 {
// Recovery threshold is mesh < 2 (design.html §lifecycle), not zero:
// a single remaining peer is a partition waiting to happen.
if remaining < 2 {
self.conn_handle.notify_recovery();
} else {
self.conn_handle.notify_growth();
@ -1364,7 +1426,7 @@ impl Network {
for peer_id in &newly_connected {
let conn = self.conn_handle.get_connection(peer_id).await;
if let Some(conn) = conn {
match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, *peer_id, anchor_addr.clone(), our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None).await {
match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, *peer_id, anchor_addr.clone(), our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None, self.conn_handle.max_bounce(), true).await {
Ok(ExchangeResult::Accepted { .. }) => {}
Ok(ExchangeResult::Refused { redirect }) => {
debug!(peer = hex::encode(peer_id), "Auto-connect refused, disconnecting");
@ -1444,10 +1506,10 @@ impl Network {
loop {
// Check slots + pick candidate via ConnHandle (no lock contention)
let available = self.conn_handle.available_local_slots().await;
let available = self.conn_handle.available_mesh_slots().await;
if available == 0 {
debug!("Growth loop: local slots full");
self.log_activity(ActivityLevel::Info, ActivityCategory::Growth, "Local slots full".into(), None);
debug!("Growth loop: mesh slots full");
self.log_activity(ActivityLevel::Info, ActivityCategory::Growth, "Mesh slots full".into(), None);
break;
}
let candidate = {
@ -1476,25 +1538,39 @@ impl Network {
self.log_activity(ActivityLevel::Info, ActivityCategory::Growth, format!("Trying candidate {} (score {:.1})", &hex::encode(candidate_id)[..8], score), Some(candidate_id));
// Resolve address via ConnHandle (no lock during I/O)
let asked_a_reporter;
let addr_str = {
let local_addr = self.conn_handle.resolve_peer_addr_local(&candidate_id).await;
if let Some(endpoint_addr) = local_addr {
asked_a_reporter = true;
endpoint_addr.ip_addrs().next().map(|a| a.to_string())
} else {
// Network resolution: get reporter connections, resolve outside lock
// Network resolution: get reporter connections, resolve
// outside lock. The horizon here must match the horizon
// the CANDIDATE SCORER uses (2..=4, same as
// `ConnectionManager::resolve_address`) — when it did
// not, the growth loop scored N4 candidates it then had
// no reporter to ask about, and burned a
// `consecutive_failures` slot plus an UNREACHABLE_EXPIRY
// suppression on every one of them.
let reporters_and_conns = {
let storage = self.storage.get().await;
let n2 = storage.find_in_n2(&candidate_id).unwrap_or_default();
let n3 = storage.find_in_n3(&candidate_id).unwrap_or_default();
let mut reporter_set: std::collections::HashSet<NodeId> =
std::collections::HashSet::new();
for bounce in 2..=4u8 {
for r in storage.find_reporters_at(&candidate_id, bounce).unwrap_or_default() {
reporter_set.insert(r);
}
}
drop(storage);
let conn_map = self.conn_handle.get_connection_map().await;
let reporter_set: std::collections::HashSet<NodeId> = n2.into_iter().chain(n3).collect();
conn_map.into_iter()
.filter(|(nid, _, _, _)| reporter_set.contains(nid))
.map(|(_, conn, _, _)| conn)
.collect::<Vec<_>>()
};
asked_a_reporter = !reporters_and_conns.is_empty();
let mut resolved = None;
for conn in reporters_and_conns {
let result: anyhow::Result<Option<String>> = async {
@ -1521,10 +1597,19 @@ impl Network {
None => {
debug!(
peer = hex::encode(candidate_id),
"Growth loop: no address, marking unreachable"
asked_a_reporter,
"Growth loop: no address"
);
self.log_activity(ActivityLevel::Warn, ActivityCategory::Growth, format!("No address for {}", &hex::encode(candidate_id)[..8]), Some(candidate_id));
self.conn_handle.mark_unreachable(&candidate_id);
// Only a candidate we could actually ask about, and
// that came back with nothing, has earned an
// UNREACHABLE_EXPIRY suppression. Failing for want of a
// connected reporter says nothing about the candidate —
// suppressing it there would hide it even after a
// shallower reporter shows up.
if asked_a_reporter {
self.conn_handle.mark_unreachable(&candidate_id);
}
consecutive_failures += 1;
if consecutive_failures >= 3 {
debug!("Growth loop: 3 consecutive failures, backing off");
@ -1560,7 +1645,7 @@ impl Network {
consecutive_failures = 0;
// Broadcast diff so peers learn about our new connection
let _ = self.broadcast_diff().await;
let _ = self.broadcast_uniques().await;
// Brief pause to let InitialExchange update N2/N3 before next pick
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
@ -1618,7 +1703,7 @@ impl Network {
if introduced {
consecutive_failures = 0;
let _ = self.broadcast_diff().await;
let _ = self.broadcast_uniques().await;
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
} else {
self.conn_handle.mark_unreachable(&candidate_id);
@ -1635,64 +1720,6 @@ impl Network {
}
}
/// Pull posts from a peer (persistent if available, ephemeral otherwise).
pub async fn pull_from_peer(&self, peer_id: &NodeId) -> anyhow::Result<PullStats> {
let conn = self.get_connection(peer_id).await?;
let (our_follows, follows_sync, our_personas) = {
let storage = self.storage.get().await;
(
storage.list_follows()?,
storage.get_follows_with_last_sync().unwrap_or_default(),
storage.list_posting_identities().unwrap_or_default(),
)
};
// Merged pull: include every posting identity we hold so DMs addressed
// to any of our personas match on recipient. Our network NodeId is
// never an author nor a wrapped_key recipient — including it would
// never match and would leak the network↔posting boundary.
let mut query_list = our_follows;
for pi in &our_personas {
if !query_list.contains(&pi.node_id) {
query_list.push(pi.node_id);
}
}
let (mut send, mut recv) = conn.open_bi().await?;
write_typed_message(
&mut send,
MessageType::PullSyncRequest,
&PullSyncRequestPayload {
follows: query_list,
since_ms: follows_sync,
},
)
.await?;
send.finish()?;
let msg_type = read_message_type(&mut recv).await?;
if msg_type != MessageType::PullSyncResponse {
anyhow::bail!("expected PullSyncResponse, got {:?}", msg_type);
}
let response: PullSyncResponsePayload = read_payload(&mut recv, 64 * 1024 * 1024).await?;
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let storage = self.storage.get().await;
let mut posts_received = 0;
for sp in &response.posts {
if !storage.is_deleted(&sp.id)? && verify_post_id(&sp.id, &sp.post) {
if storage.store_post_with_visibility(&sp.id, &sp.post, &sp.visibility)? {
posts_received += 1;
}
// Protocol v4: update last_sync_ms for the author
let _ = storage.update_follow_last_sync(&sp.post.author, now_ms);
}
}
Ok(PullStats {
peers_pulled: 1,
posts_received,
})
}
/// Send a uni-stream message. Uses persistent connection if available, ephemeral otherwise.
pub async fn send_to_peer_uni<T: Serialize>(
&self,
@ -1940,41 +1967,146 @@ impl Network {
Some(addr)
}
// ---- Anchor referral delegation ----
// ---- Anchor convection delegation (design.html §anchors) ----
/// Request peer referrals from an anchor peer (mesh or session).
/// Does NOT hold conn_mgr lock during network I/O.
pub async fn request_anchor_referrals(
/// Ask an anchor for peers. The request itself enrols us in that anchor's
/// rolling window — there is no registration message; 0xC0 is retired.
///
/// Does NOT hold the conn_mgr lock during network I/O. A refusal comes back
/// as a single small message, so the `refused` case costs one round trip,
/// not a timeout.
pub async fn request_convection(
&self,
anchor: &NodeId,
) -> anyhow::Result<Vec<crate::protocol::AnchorReferral>> {
class: crate::protocol::ConvectionClass,
) -> anyhow::Result<crate::protocol::ConvectionResponsePayload> {
let conn = self.conn_handle.get_any_connection(anchor).await
.ok_or_else(|| anyhow::anyhow!("anchor peer not connected (mesh or session)"))?;
// Self-reported addresses, best first: the stable advertised address
// (if we are an anchor ourselves), then the UPnP external mapping, then
// whatever the endpoint knows. The anchor prefers what it OBSERVES over
// all of these; these only help when it cannot observe us.
let endpoint = self.conn_handle.endpoint().await;
let our_addrs: Vec<String> = endpoint.addr().ip_addrs()
let mut our_addrs: Vec<String> = endpoint.addr().ip_addrs()
.filter(|s| is_publicly_routable(s))
.map(|s| s.to_string())
.collect();
if let Some(ext) = self.conn_handle.upnp_external_addr().await {
let ext_str = ext.to_string();
if !our_addrs.contains(&ext_str) {
our_addrs.insert(0, ext_str);
}
}
if let Some(anchor_addr) = self.conn_handle.build_anchor_advertised_addr().await {
if !our_addrs.contains(&anchor_addr) {
our_addrs.insert(0, anchor_addr);
}
}
let request = crate::protocol::AnchorReferralRequestPayload {
let request = crate::protocol::ConvectionRequestPayload {
requester: self.our_node_id,
requester_addresses: our_addrs,
class,
};
let (mut send, mut recv) = conn.open_bi().await?;
crate::protocol::write_typed_message(&mut send, crate::protocol::MessageType::AnchorReferralRequest, &request).await?;
crate::protocol::write_typed_message(&mut send, crate::protocol::MessageType::ConvectionRequest, &request).await?;
send.finish()?;
let msg_type = crate::protocol::read_message_type(&mut recv).await?;
if msg_type != crate::protocol::MessageType::AnchorReferralResponse {
anyhow::bail!("expected AnchorReferralResponse, got {:?}", msg_type);
if msg_type != crate::protocol::MessageType::ConvectionResponse {
anyhow::bail!("expected ConvectionResponse, got {:?}", msg_type);
}
let response: crate::protocol::AnchorReferralResponsePayload = crate::protocol::read_payload(&mut recv, 4096).await?;
let response: crate::protocol::ConvectionResponsePayload =
crate::protocol::read_payload(&mut recv, 4096).await?;
// Touch session to prevent idle reaping
self.conn_handle.touch_session_if_exists(anchor);
Ok(response.referrals)
// Adaptive feedback (round 5): a refusal shifts weight toward mesh
// introductions and other anchors; a served request drifts it back.
if response.refused {
debug!(anchor = hex::encode(anchor), "Convection: refused (top-up, no capacity)");
self.conn_handle.record_convection_refusal(anchor);
} else {
self.conn_handle.record_convection_success(anchor);
}
// The NAT filter probe used to ride the register message. It has to
// ride something, and this is now the only message we send an anchor.
if self.conn_handle.nat_filtering().await == crate::types::NatFiltering::Unknown {
if let Err(e) = self.request_nat_filter_probe(anchor).await {
debug!(error = %e, "NAT filter probe request failed");
}
}
Ok(response)
}
/// Act on one convection response: connect to each referral, landing it in
/// whatever slot class is free (mesh first, then the temp referral band
/// above the cap). Returns how many connections were established.
///
/// `introduced` referrals skip the extra introduction round trip — the
/// anchor already pushed a RelayIntroduce toward them naming us, so they
/// are punching outward while we dial.
pub async fn act_on_convection(
&self,
anchor: &NodeId,
response: &crate::protocol::ConvectionResponsePayload,
) -> usize {
let mut connected = 0;
for referral in &response.referrals {
if referral.node_id == self.our_node_id {
continue;
}
if self.conn_handle.is_connected(&referral.node_id).await {
continue;
}
let mut ok = false;
// bugs-fixed #8 (receive side): a referral address is a third
// party's claim. Dialling `127.0.0.1:22` or `192.168.1.1:443`
// because an anchor said so is attacker-directed internal probing
// from our own host, and every such connect() enters iroh's
// per-endpoint path store permanently (v0.7.3 EDM lesson).
let usable: Option<&String> = referral.addresses.iter().find(|a| {
a.parse::<std::net::SocketAddr>()
.map(|sa| crate::connection::convection_addr_ok(&sa))
.unwrap_or(false)
});
if let Some(addr_str) = usable {
let connect_str = format!("{}@{}", hex::encode(referral.node_id), addr_str);
if let Ok((rid, raddr)) = crate::parse_connect_string(&connect_str) {
let fut = self.connect_to_peer(rid, raddr);
match tokio::time::timeout(std::time::Duration::from_secs(15), fut).await {
Ok(Ok(())) => {
info!(peer = hex::encode(rid), "Convection: connected to referred peer");
ok = true;
}
Ok(Err(e)) => debug!(error = %e, peer = hex::encode(rid), "Convection: direct connect failed"),
Err(_) => debug!(peer = hex::encode(rid), "Convection: direct connect timed out"),
}
}
}
if !ok && !referral.introduced {
// No coordinated introduction was possible — ask for one.
match self.connect_via_introduction_as_mesh(referral.node_id, *anchor).await {
Ok(()) => {
info!(peer = hex::encode(referral.node_id), "Convection: connected via introduction");
ok = true;
}
Err(e) => debug!(error = %e, peer = hex::encode(referral.node_id), "Convection: introduction failed"),
}
}
if ok {
connected += 1;
}
}
if connected > 0 {
self.notify_growth().await;
}
connected
}
/// Request NAT filter probe from an anchor (determines address-restricted vs port-restricted).
@ -2028,55 +2160,6 @@ impl Network {
Ok(())
}
/// Register our address with an anchor peer (mesh or session).
/// Also runs NAT filter probe if filtering is still Unknown.
/// Does NOT hold conn_mgr lock during network I/O.
pub async fn send_anchor_register(&self, anchor: &NodeId) -> anyhow::Result<()> {
let conn = self.conn_handle.get_any_connection(anchor).await
.ok_or_else(|| anyhow::anyhow!("anchor peer not connected (mesh or session)"))?;
let endpoint = self.conn_handle.endpoint().await;
let mut our_addrs: Vec<String> = endpoint.addr().ip_addrs()
.map(|s| s.to_string())
.collect();
// Prepend UPnP external address (most useful for remote peers)
if let Some(ext) = self.conn_handle.upnp_external_addr().await {
let ext_str = ext.to_string();
if !our_addrs.contains(&ext_str) {
our_addrs.insert(0, ext_str);
}
}
// Prepend stable anchor advertised address
if let Some(anchor_addr) = self.conn_handle.build_anchor_advertised_addr().await {
if !our_addrs.contains(&anchor_addr) {
our_addrs.insert(0, anchor_addr);
}
}
let payload = crate::protocol::AnchorRegisterPayload {
node_id: self.our_node_id,
addresses: our_addrs,
};
let mut send = conn.open_uni().await?;
crate::protocol::write_typed_message(&mut send, crate::protocol::MessageType::AnchorRegister, &payload).await?;
send.finish()?;
// Touch session to prevent idle reaping
self.conn_handle.touch_session_if_exists(anchor);
debug!(anchor = hex::encode(anchor), "Registered with anchor");
// Also run NAT filter probe if filtering is still Unknown
let needs_probe = self.conn_handle.nat_filtering().await == crate::types::NatFiltering::Unknown;
if needs_probe {
if let Err(e) = self.request_nat_filter_probe(anchor).await {
debug!(error = %e, "NAT filter probe request failed");
}
}
Ok(())
}
/// Check if a peer is a known anchor.
pub async fn is_anchor_peer(&self, node_id: &NodeId) -> bool {
let storage = self.storage.get().await;

File diff suppressed because it is too large Load diff

View file

@ -27,13 +27,25 @@ pub struct SyncPost {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum MessageType {
NodeListUpdate = 0x01,
UniquesAnnounce = 0x01,
InitialExchange = 0x02,
AddressRequest = 0x03,
AddressResponse = 0x04,
RefuseRedirect = 0x05,
PullSyncRequest = 0x40,
PullSyncResponse = 0x41,
/// The uniques-index exchange (design.html §sync). A pull is no longer a
/// post transfer: it is "if you want these IDs, talk to me and I'll help
/// you find them". Symmetric — request carries our pools, response carries
/// theirs, one round trip updates both sides.
UniquesPullRequest = 0x40,
UniquesPullResponse = 0x41,
/// TRANSITIONAL (Iteration D folds this into the update-cadence scheduler +
/// CDN replication). Content still has to move somewhere while
/// PostFetchRequest (0xD4) serves Public posts only: DMs, group posts,
/// Friends and FoFClosed posts have no other carrier. Splitting it off
/// 0x40/0x41 keeps §sync honest — a *pull* is not a post transfer, because
/// post transfer is not a pull.
ContentSyncRequest = 0x46,
ContentSyncResponse = 0x47,
ProfileUpdate = 0x50,
WormQuery = 0x60,
WormResponse = 0x61,
@ -49,9 +61,11 @@ pub enum MessageType {
RelayIntroduceResult = 0xB1,
SessionRelay = 0xB2,
CircleProfileUpdate = 0xB4,
AnchorRegister = 0xC0,
AnchorReferralRequest = 0xC1,
AnchorReferralResponse = 0xC2,
// 0xC0 retired in v0.8 — anchors keep a rolling window of recent callers
// fed by the request itself; there is no registration message and no
// registration database.
ConvectionRequest = 0xC1,
ConvectionResponse = 0xC2,
AnchorProbeRequest = 0xC3,
AnchorProbeResult = 0xC4,
PortScanHeartbeat = 0xC5,
@ -73,13 +87,15 @@ pub enum MessageType {
impl MessageType {
pub fn from_byte(b: u8) -> Option<Self> {
match b {
0x01 => Some(Self::NodeListUpdate),
0x01 => Some(Self::UniquesAnnounce),
0x02 => Some(Self::InitialExchange),
0x03 => Some(Self::AddressRequest),
0x04 => Some(Self::AddressResponse),
0x05 => Some(Self::RefuseRedirect),
0x40 => Some(Self::PullSyncRequest),
0x41 => Some(Self::PullSyncResponse),
0x40 => Some(Self::UniquesPullRequest),
0x41 => Some(Self::UniquesPullResponse),
0x46 => Some(Self::ContentSyncRequest),
0x47 => Some(Self::ContentSyncResponse),
0x50 => Some(Self::ProfileUpdate),
0x60 => Some(Self::WormQuery),
0x61 => Some(Self::WormResponse),
@ -94,9 +110,8 @@ impl MessageType {
0xB1 => Some(Self::RelayIntroduceResult),
0xB2 => Some(Self::SessionRelay),
0xB4 => Some(Self::CircleProfileUpdate),
0xC0 => Some(Self::AnchorRegister),
0xC1 => Some(Self::AnchorReferralRequest),
0xC2 => Some(Self::AnchorReferralResponse),
0xC1 => Some(Self::ConvectionRequest),
0xC2 => Some(Self::ConvectionResponse),
0xC3 => Some(Self::AnchorProbeRequest),
0xC4 => Some(Self::AnchorProbeResult),
0xC5 => Some(Self::PortScanHeartbeat),
@ -124,13 +139,152 @@ impl MessageType {
// --- Payload structs ---
/// Initial exchange: N1/N2 node lists + profile + deletes + post_ids + peer addresses
/// A packed set of 32-byte IDs: base64 of the raw concatenated bytes.
///
/// `NodeId = [u8; 32]` serialised through serde_json becomes a JSON array of 32
/// decimal numbers (~100-130 bytes/ID — a 3-4x blowup). The uniques pools can
/// carry thousands of IDs, so they ride packed instead.
pub type PackedIds = String;
/// Encode a set of IDs into a packed base64 blob.
pub fn pack_ids(ids: &[NodeId]) -> PackedIds {
use base64::Engine;
let mut raw = Vec::with_capacity(ids.len() * 32);
for id in ids {
raw.extend_from_slice(id);
}
base64::engine::general_purpose::STANDARD.encode(raw)
}
/// Decode a packed base64 blob back into IDs. Trailing partial IDs are dropped.
pub fn unpack_ids(packed: &str) -> Vec<NodeId> {
use base64::Engine;
let raw = match base64::engine::general_purpose::STANDARD.decode(packed) {
Ok(r) => r,
Err(_) => return Vec::new(),
};
raw.chunks_exact(32)
.map(|c| {
let mut id = [0u8; 32];
id.copy_from_slice(c);
id
})
.collect()
}
/// How many 32-byte IDs a packed base64 blob holds, WITHOUT decoding it.
///
/// Standard base64 emits 4 chars per 3 input bytes (padded), so decoded length
/// is `chars / 4 * 3` minus padding; integer division by 32 absorbs the padding
/// slack for any non-empty blob. Exact for every blob `pack_ids` produces.
pub fn packed_id_count(packed: &str) -> usize {
(packed.len() / 4) * 3 / 32
}
/// The ONLY entry type in a uniques pool that carries an address.
///
/// design.html §layers: anchor entries carry addresses so the pools double as
/// the anchor directory; every other entry is a bare ID (there is no point
/// sharing an address that needs an introduction anyway). Addresses MUST be
/// filtered through `is_publicly_routable()` before they go on the wire.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnchorEntry {
/// hex node id (anchors are always node-class)
pub i: String,
/// e.g. ["1.2.3.4:4433"]
pub a: Vec<String>,
}
/// One slice of a uniques pool. Node-class and author-class IDs live in
/// separate arrays so a receiver structurally cannot mistake a persona ID for
/// a connectable node ID.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UniquesSlice {
/// Connectable network IDs, bare.
#[serde(default)]
pub nodes: PackedIds,
/// Posting/persona/throwaway IDs, bare. CDN-resolvable only — never a
/// connect target.
#[serde(default)]
pub authors: PackedIds,
/// Address-bearing anchor entries (node-class by definition).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub anchors: Vec<AnchorEntry>,
}
impl UniquesSlice {
pub fn is_empty(&self) -> bool {
self.nodes.is_empty() && self.authors.is_empty() && self.anchors.is_empty()
}
/// Number of IDs carried (for logging / budget checks).
///
/// `pack_ids` concatenates ALL IDs and base64-encodes the blob ONCE, so the
/// encoded length is `4 * ceil(32n/3)` characters — about 42.67 per ID, not
/// 44 (44 is the encoding of exactly ONE 32-byte ID). Counting `len()/44`
/// undercounts by ~3% at scale and by 33% for a 3-ID blob. Since this backs
/// budget checks, it is computed from the DECODED byte length instead.
pub fn len(&self) -> usize {
packed_id_count(&self.nodes) + packed_id_count(&self.authors) + self.anchors.len()
}
}
/// 0x01 — the two-pool uniques announce (design.html §layers).
///
/// POOL 1 (`fwd`) is FORWARDABLE and indexed by OUR bounce:
/// fwd[0] = N0 — ourselves (and our own anchor address, if we are an anchor)
/// fwd[1] = N1 — our own uniques: mesh peers, social directs, CDN file
/// authors, CDN file-holder peers, merged so the receiver
/// cannot tell which source an entry came from
/// fwd[2] = N2 — what our N1 reported to us
/// The receiver stores `fwd[i]` at bounce `i + 1` (their N1..N3), tagged to us
/// as reporter, and MAY re-announce it shifted one deeper.
///
/// POOL 2 (`term`) is TERMINAL: our N3, deduplicated against every fwd slice.
/// The receiver stores it as their N4 and USES it for search / address
/// resolution, but NEVER re-announces it. The structural separation — a
/// distinct field, not a flag in a merged list — is what makes "never
/// re-announce" un-forgettable at the announce builder: the builder simply
/// never reads bounce-4 rows. There is no N5 anywhere.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UniquesAnnouncePayload {
pub seq: u64,
/// Always true in v0.8 — the announce is a full snapshot that replaces this
/// reporter's rows. (Incremental diffing over four layers with anchor
/// metadata was not worth the divergence risk; the sender skips the send
/// entirely when nothing changed, which recovers most of the saving.)
pub full: bool,
/// Pool 1, forwardable. Always length 3 (bounces 0, 1, 2).
pub fwd: Vec<UniquesSlice>,
/// Pool 2, terminal. Empty when the recipient advertised `depth < 4`.
#[serde(default)]
pub term: UniquesSlice,
/// Deepest bounce the SENDER retains (4 desktop, 3 mobile). Lets the peer
/// skip building/sending the terminal pool for us — the single biggest
/// bandwidth saving on a cellular link.
pub depth: u8,
}
impl UniquesAnnouncePayload {
pub fn empty(seq: u64, depth: u8) -> Self {
Self {
seq,
full: true,
fwd: vec![UniquesSlice::default(), UniquesSlice::default(), UniquesSlice::default()],
term: UniquesSlice::default(),
depth,
}
}
}
/// Initial exchange: uniques announce + profile + deletes + post_ids + peer addresses
#[derive(Debug, Serialize, Deserialize)]
pub struct InitialExchangePayload {
/// Our connections + social contacts NodeIds (no addresses)
pub n1_node_ids: Vec<NodeId>,
/// Our deduplicated N2 NodeIds (no addresses)
pub n2_node_ids: Vec<NodeId>,
/// Our uniques pools (full snapshot). `None` means "this connection carries
/// no knowledge" — i.e. the sender placed us in a temporary referral slot.
/// Explicit on the wire rather than a pair of empty vectors so the receiver
/// can tell "nothing to share" from "not sharing".
#[serde(default)]
pub uniques: Option<UniquesAnnouncePayload>,
/// Our profile
pub profile: Option<PublicProfile>,
/// Our delete records
@ -162,28 +316,32 @@ pub struct InitialExchangePayload {
pub duplicate_active: Option<bool>,
}
/// Incremental N1/N2 changes
/// 0x40/0x41 — the uniques-index exchange, both directions.
///
/// The requester sends its pools; the responder answers with its own. One
/// round trip refreshes both indexes, which is what makes an index *exchange*
/// rather than a fetch. Content does not ride this path.
#[derive(Debug, Serialize, Deserialize)]
pub struct NodeListUpdatePayload {
pub seq: u64,
pub n1_added: Vec<NodeId>,
pub n1_removed: Vec<NodeId>,
pub n2_added: Vec<NodeId>,
pub n2_removed: Vec<NodeId>,
pub struct UniquesPullPayload {
/// The sender's two pools. `None` = "I carry no knowledge on this link"
/// (temp referral slot), mirroring `InitialExchangePayload.uniques`.
#[serde(default)]
pub uniques: Option<UniquesAnnouncePayload>,
}
/// Pull-based post sync request
/// 0x46 — TRANSITIONAL content sync request. Identical in shape to the v0.7
/// pull request; only the opcode moved. See `MessageType::ContentSyncRequest`.
#[derive(Debug, Serialize, Deserialize)]
pub struct PullSyncRequestPayload {
pub struct ContentSyncRequestPayload {
/// Our follows (for the responder to filter)
pub follows: Vec<NodeId>,
/// Per-author last-sync timestamps (Vec of tuples for serde compat)
pub since_ms: Vec<(NodeId, u64)>,
}
/// Pull-based post sync response
/// 0x47 — TRANSITIONAL content sync response.
#[derive(Debug, Serialize, Deserialize)]
pub struct PullSyncResponsePayload {
pub struct ContentSyncResponsePayload {
pub posts: Vec<SyncPost>,
}
@ -387,33 +545,63 @@ pub struct CircleProfileUpdatePayload {
pub updated_at: u64,
}
// --- Anchor referral payloads ---
// --- Anchor convection payloads (design.html §anchors) ---
/// Node registers its address with an anchor (uni-stream)
#[derive(Debug, Serialize, Deserialize)]
pub struct AnchorRegisterPayload {
pub node_id: NodeId,
pub addresses: Vec<String>,
/// The one request-class bit (round-5 ruling).
///
/// ENTRY = bootstrap or recovery, i.e. the caller has fewer than 2 mesh
/// connections. Always served — a node that cannot get in cannot come back.
/// TOP_UP = growth with a working mesh. Served capacity-permitting and refused
/// CHEAPLY (a single response message, no timeout burned). Top-ups are the
/// convection medium — they are what keeps referral windows fresh — so they
/// are not throttled aggressively; the refusal doubles as the adaptive load
/// signal the caller feeds back into its action weights.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConvectionClass {
Entry,
TopUp,
}
/// Node requests peer referrals from an anchor (bi-stream request)
impl ConvectionClass {
/// Entry class is decided purely by "can this node function right now".
pub fn for_mesh_count(mesh_count: usize) -> Self {
if mesh_count < 2 { Self::Entry } else { Self::TopUp }
}
pub fn is_entry(self) -> bool {
matches!(self, Self::Entry)
}
}
/// 0xC1 — "I'm joining / I need peers". The request itself is what enrols the
/// caller in the anchor's rolling window; there is no separate registration.
#[derive(Debug, Serialize, Deserialize)]
pub struct AnchorReferralRequestPayload {
pub struct ConvectionRequestPayload {
pub requester: NodeId,
pub requester_addresses: Vec<String>,
pub class: ConvectionClass,
}
/// Anchor responds with peer referrals (bi-stream response)
/// 0xC2 — up to 2 recent prior callers, or a cheap refusal.
#[derive(Debug, Serialize, Deserialize)]
pub struct AnchorReferralResponsePayload {
pub referrals: Vec<AnchorReferral>,
pub struct ConvectionResponsePayload {
pub referrals: Vec<ConvectionReferral>,
/// Cheap refusal of a TOP_UP request: one message, immediately, so the
/// caller never burns a timeout. Never set for ENTRY-class requests.
#[serde(default)]
pub refused: bool,
}
/// A single peer referral from an anchor
/// A single referral out of the anchor's rolling caller window.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnchorReferral {
pub struct ConvectionReferral {
pub node_id: NodeId,
pub addresses: Vec<String>,
/// The anchor had BOTH ends live and has pushed a RelayIntroduce toward
/// this peer naming the caller as requester — so the peer is already
/// punching outward. Dial straight through instead of paying for a second
/// introduction round trip.
#[serde(default)]
pub introduced: bool,
}
// --- Anchor probe payloads ---
@ -634,13 +822,15 @@ mod tests {
#[test]
fn message_type_roundtrip() {
let types = [
MessageType::NodeListUpdate,
MessageType::UniquesAnnounce,
MessageType::InitialExchange,
MessageType::AddressRequest,
MessageType::AddressResponse,
MessageType::RefuseRedirect,
MessageType::PullSyncRequest,
MessageType::PullSyncResponse,
MessageType::UniquesPullRequest,
MessageType::UniquesPullResponse,
MessageType::ContentSyncRequest,
MessageType::ContentSyncResponse,
MessageType::ProfileUpdate,
MessageType::WormQuery,
MessageType::WormResponse,
@ -655,9 +845,8 @@ mod tests {
MessageType::RelayIntroduceResult,
MessageType::SessionRelay,
MessageType::CircleProfileUpdate,
MessageType::AnchorRegister,
MessageType::AnchorReferralRequest,
MessageType::AnchorReferralResponse,
MessageType::ConvectionRequest,
MessageType::ConvectionResponse,
MessageType::AnchorProbeRequest,
MessageType::AnchorProbeResult,
MessageType::PortScanHeartbeat,
@ -779,56 +968,105 @@ mod tests {
}
#[test]
fn anchor_register_payload_roundtrip() {
let payload = AnchorRegisterPayload {
node_id: [1u8; 32],
addresses: vec!["192.168.1.5:4433".to_string(), "10.0.0.1:4433".to_string()],
};
let json = serde_json::to_string(&payload).unwrap();
let decoded: AnchorRegisterPayload = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.node_id, [1u8; 32]);
assert_eq!(decoded.addresses.len(), 2);
assert_eq!(decoded.addresses[0], "192.168.1.5:4433");
fn convection_request_carries_the_class_bit() {
for class in [ConvectionClass::Entry, ConvectionClass::TopUp] {
let payload = ConvectionRequestPayload {
requester: [2u8; 32],
requester_addresses: vec!["10.0.0.2:4433".to_string()],
class,
};
let json = serde_json::to_string(&payload).unwrap();
let decoded: ConvectionRequestPayload = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.requester, [2u8; 32]);
assert_eq!(decoded.requester_addresses, vec!["10.0.0.2:4433"]);
assert_eq!(decoded.class, class);
}
// The class is derived purely from "can this node function right now".
assert!(ConvectionClass::for_mesh_count(0).is_entry());
assert!(ConvectionClass::for_mesh_count(1).is_entry());
assert!(!ConvectionClass::for_mesh_count(2).is_entry());
assert!(!ConvectionClass::for_mesh_count(20).is_entry());
}
#[test]
fn anchor_referral_request_payload_roundtrip() {
let payload = AnchorReferralRequestPayload {
requester: [2u8; 32],
requester_addresses: vec!["10.0.0.2:4433".to_string()],
};
let json = serde_json::to_string(&payload).unwrap();
let decoded: AnchorReferralRequestPayload = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.requester, [2u8; 32]);
assert_eq!(decoded.requester_addresses, vec!["10.0.0.2:4433"]);
}
#[test]
fn anchor_referral_response_payload_roundtrip() {
let payload = AnchorReferralResponsePayload {
fn convection_response_roundtrip_including_cheap_refusal() {
let payload = ConvectionResponsePayload {
referrals: vec![
AnchorReferral {
ConvectionReferral {
node_id: [3u8; 32],
addresses: vec!["10.0.0.3:4433".to_string()],
introduced: false,
},
AnchorReferral {
ConvectionReferral {
node_id: [4u8; 32],
addresses: vec!["10.0.0.4:4433".to_string(), "192.168.1.4:4433".to_string()],
addresses: vec!["10.0.0.4:4433".to_string()],
introduced: true,
},
],
refused: false,
};
let json = serde_json::to_string(&payload).unwrap();
let decoded: AnchorReferralResponsePayload = serde_json::from_str(&json).unwrap();
let decoded: ConvectionResponsePayload = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.referrals.len(), 2);
assert_eq!(decoded.referrals[0].node_id, [3u8; 32]);
assert_eq!(decoded.referrals[0].addresses, vec!["10.0.0.3:4433"]);
assert_eq!(decoded.referrals[1].node_id, [4u8; 32]);
assert_eq!(decoded.referrals[1].addresses.len(), 2);
assert!(!decoded.referrals[0].introduced);
assert!(decoded.referrals[1].introduced);
assert!(!decoded.refused);
// Empty referrals
let empty = AnchorReferralResponsePayload { referrals: vec![] };
let json2 = serde_json::to_string(&empty).unwrap();
let decoded2: AnchorReferralResponsePayload = serde_json::from_str(&json2).unwrap();
// A refusal is one small message with no referrals — the caller reads
// it immediately instead of burning a timeout.
let refusal = ConvectionResponsePayload { referrals: vec![], refused: true };
let json2 = serde_json::to_string(&refusal).unwrap();
assert!(json2.len() < 64, "refusal must stay tiny: {}", json2);
let decoded2: ConvectionResponsePayload = serde_json::from_str(&json2).unwrap();
assert!(decoded2.refused);
assert!(decoded2.referrals.is_empty());
}
#[test]
fn uniques_pull_is_an_index_exchange_not_a_post_transfer() {
let payload = UniquesPullPayload {
uniques: Some(UniquesAnnouncePayload::empty(7, 4)),
};
let json = serde_json::to_string(&payload).unwrap();
let decoded: UniquesPullPayload = serde_json::from_str(&json).unwrap();
let u = decoded.uniques.unwrap();
assert_eq!(u.seq, 7);
assert_eq!(u.depth, 4);
assert_eq!(u.fwd.len(), 3);
// No post field exists on this payload at all — content rides 0x46/0x47.
assert!(!json.contains("posts"));
// A temp referral slot carries no knowledge in either direction.
let none = UniquesPullPayload { uniques: None };
let json2 = serde_json::to_string(&none).unwrap();
let decoded2: UniquesPullPayload = serde_json::from_str(&json2).unwrap();
assert!(decoded2.uniques.is_none());
}
/// `len()` backs budget checks, so it must be EXACT — not the old
/// `bytes / 44` approximation, which assumed one base64 encoding per ID
/// when `pack_ids` encodes the whole concatenated blob once.
#[test]
fn uniques_slice_len_counts_packed_ids_exactly() {
for n in [0usize, 1, 2, 3, 7, 100, 1000] {
let ids: Vec<crate::types::NodeId> = (0..n)
.map(|i| {
let mut id = [0u8; 32];
id[0] = (i % 251) as u8;
id[1] = (i / 251) as u8;
id
})
.collect();
let packed = pack_ids(&ids);
assert_eq!(packed_id_count(&packed), n, "packed_id_count for n={}", n);
assert_eq!(unpack_ids(&packed).len(), n);
let slice = UniquesSlice {
nodes: packed.clone(),
authors: packed.clone(),
anchors: vec![AnchorEntry { i: "aa".into(), a: vec!["1.2.3.4:1".into()] }],
};
assert_eq!(slice.len(), n * 2 + 1, "UniquesSlice::len for n={}", n);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -649,26 +649,67 @@ impl NatProfile {
}
/// Device profile — determines connection slot budget
///
/// v0.8 (Iteration C): the Local/Wide split is gone. There is ONE mesh pool
/// (`mesh_slots`) plus a strictly-additional band of temporary referral slots
/// (`temp_referral_slots`) that live ABOVE the mesh cap and carry no knowledge
/// exchange. See design.html §connections.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeviceProfile {
/// Desktop: 71 local + 20 wide = 91 mesh connections
/// Desktop: 20 mesh + up to 10 temp referral
Desktop,
/// Mobile: 7 local + 5 wide = 12 mesh connections
/// Mobile: 15 mesh + up to 4 temp referral
Mobile,
}
impl DeviceProfile {
pub fn local_slots(&self) -> usize {
/// The single mesh pool size (v0.8 narrow mesh, deep knowledge).
///
/// `ITSGOIN_TEST_MESH_SLOTS` overrides it so integration tests can reach
/// the temp-referral boundary with a handful of nodes instead of 21. Test
/// gate only — same pattern as `ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS`.
///
/// Read ONCE into a `OnceLock`: this is called from `ConnectionManager`
/// construction and from every path that wants a fresh cap, and an env
/// lookup per call is a syscall-shaped cost on a hot path (and lets the
/// mesh cap change under a running node, which nothing expects).
pub fn mesh_slots(&self) -> usize {
static OVERRIDE: std::sync::OnceLock<Option<usize>> = std::sync::OnceLock::new();
let over = *OVERRIDE.get_or_init(|| {
std::env::var("ITSGOIN_TEST_MESH_SLOTS")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.filter(|n| *n > 0)
});
if let Some(n) = over {
return n;
}
match self {
DeviceProfile::Desktop => 71,
DeviceProfile::Mobile => 7,
DeviceProfile::Desktop => 20,
DeviceProfile::Mobile => 15,
}
}
pub fn wide_slots(&self) -> usize {
/// Temporary referral slots, allocated ABOVE `mesh_slots`. These exist only
/// to facilitate introductions: they carry no uniques exchange, are never
/// persisted to `mesh_peers`, and either graduate into a freed mesh slot or
/// expire. Ruling: +4..+10 above the cap.
pub fn temp_referral_slots(&self) -> usize {
match self {
DeviceProfile::Desktop => 20,
DeviceProfile::Mobile => 5,
DeviceProfile::Desktop => 10,
DeviceProfile::Mobile => 4,
}
}
/// Deepest bounce this device stores in the `reachable` index.
/// Desktop keeps the full 4-bounce horizon; mobile caps at 3 (it drops the
/// terminal pool on receipt and advertises `depth = 3` so senders skip
/// building it). Alternative considered and NOT implemented: Bloom-compress
/// N4 on mobile (see `apply_uniques_announce` doc comment).
pub fn max_bounce(&self) -> u8 {
match self {
DeviceProfile::Desktop => 4,
DeviceProfile::Mobile => 3,
}
}
@ -708,24 +749,103 @@ impl std::fmt::Display for SessionReachMethod {
}
}
/// Slot kind for mesh connections
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PeerSlotKind {
/// Diverse local connections (Desktop: 71, Mobile: 7)
Local,
/// Bloom-sourced random distant connections (Desktop: 20, Mobile: 5)
Wide,
/// Which class of slot a live connection occupies.
///
/// v0.8: `Mesh` is the real pool (capped at `DeviceProfile::mesh_slots`) and is
/// the ONLY class that participates in the uniques announce. `TempReferral`
/// slots sit above the cap, exist purely to broker introductions, and are never
/// written to `mesh_peers` — which is what structurally keeps them out of our
/// own announcements. They graduate into a freed mesh slot or expire.
///
/// NOT on the wire — purely local accounting.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MeshSlot {
Mesh,
TempReferral { expires_at_ms: u64 },
}
impl std::fmt::Display for PeerSlotKind {
impl MeshSlot {
pub fn is_mesh(&self) -> bool {
matches!(self, MeshSlot::Mesh)
}
pub fn is_temp(&self) -> bool {
!self.is_mesh()
}
}
impl std::fmt::Display for MeshSlot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PeerSlotKind::Local => write!(f, "local"),
PeerSlotKind::Wide => write!(f, "wide"),
MeshSlot::Mesh => write!(f, "mesh"),
MeshSlot::TempReferral { .. } => write!(f, "temp"),
}
}
}
/// Class of an ID carried in a uniques pool.
///
/// A `Node` ID is a network identity we may connect to / hole-punch / relay to.
/// An `Author` ID is a posting identity (persona, throwaway, greeting ID). It
/// has NO address and NO device linkage (Iteration A stripped
/// `AuthorManifest.author_addresses`), so it must NEVER enter the address
/// resolution cascade or the growth-candidate scorer — it resolves only through
/// CDN holder search. The pools keep the two classes in separate arrays so a
/// receiver structurally cannot confuse them.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IdClass {
Node = 0,
Author = 1,
}
impl IdClass {
pub fn as_i64(self) -> i64 {
self as i64
}
pub fn from_i64(v: i64) -> Self {
if v == 1 { IdClass::Author } else { IdClass::Node }
}
}
/// One entry in the uniques index / announce pools.
///
/// `addresses` is non-empty ONLY when `is_anchor` — that is the §layers privacy
/// invariant, enforced at the store (`add_reach` blanks the column otherwise)
/// and at the wire builder (only `AnchorEntry` has an address field at all).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReachEntry {
pub id: NodeId,
pub id_class: IdClass,
pub is_anchor: bool,
pub addresses: Vec<String>,
}
impl ReachEntry {
/// A bare node-class entry (no address).
pub fn node(id: NodeId) -> Self {
Self { id, id_class: IdClass::Node, is_anchor: false, addresses: Vec::new() }
}
/// A bare author-class entry (no address, never a connect target).
pub fn author(id: NodeId) -> Self {
Self { id, id_class: IdClass::Author, is_anchor: false, addresses: Vec::new() }
}
/// An anchor entry — the only kind that carries an address.
pub fn anchor(id: NodeId, addresses: Vec<String>) -> Self {
Self { id, id_class: IdClass::Node, is_anchor: true, addresses }
}
}
/// The two announce pools, pre-wire.
///
/// `fwd` is always length 3 (our N0, N1, N2) and is FORWARDABLE — the receiver
/// stores `fwd[i]` at bounce `i + 1` and may re-announce it shifted.
/// `term` is our N3 and is TERMINAL — the receiver stores it at bounce 4, uses
/// it, and never re-announces it.
#[derive(Debug, Clone, Default)]
pub struct UniquesPools {
pub fwd: Vec<Vec<ReachEntry>>,
pub term: Vec<ReachEntry>,
}
// --- Social Routing Cache ---
/// How we last reached a social contact

View file

@ -69,7 +69,7 @@
use std::net::SocketAddr;
use std::num::NonZeroU16;
use std::time::Duration;
use tracing::{debug, info, warn};
use tracing::{debug, info};
/// An active port mapping. While this value is held, the underlying
/// `portmapper::Client` keeps the lease alive in a background task.