No central server, user-owned data, reverse-chronological feed. Rust core + Tauri desktop + Android app + plain HTML/CSS/JS frontend. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
7079 lines
287 KiB
Rust
7079 lines
287 KiB
Rust
use std::collections::{HashMap, HashSet};
|
|
use std::net::SocketAddr;
|
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
use std::sync::Arc;
|
|
|
|
use tokio::sync::Mutex;
|
|
use tracing::{debug, info, trace, warn};
|
|
|
|
use crate::activity::{ActivityCategory, ActivityLevel, ActivityLog};
|
|
use crate::blob::BlobStore;
|
|
use crate::content::verify_post_id;
|
|
use crate::crypto;
|
|
use crate::protocol::{
|
|
read_message_type, read_payload, write_typed_message, AnchorReferral,
|
|
AnchorReferralRequestPayload, AnchorReferralResponsePayload, AnchorRegisterPayload,
|
|
AudienceRequestPayload, AudienceResponsePayload, BlobHeaderDiffPayload,
|
|
BlobHeaderRequestPayload, BlobHeaderResponsePayload, BlobRequestPayload, BlobResponsePayload,
|
|
CircleProfileUpdatePayload, GroupKeyDistributePayload, GroupKeyRequestPayload,
|
|
GroupKeyResponsePayload, InitialExchangePayload, MeshPreferPayload,
|
|
MessageType, NodeListUpdatePayload, PostDownstreamRegisterPayload,
|
|
PostNotificationPayload, PostPushPayload,
|
|
ProfileUpdatePayload, PullSyncRequestPayload, PullSyncResponsePayload,
|
|
RefuseRedirectPayload, RelayIntroducePayload, RelayIntroduceResultPayload, SessionRelayPayload,
|
|
SocialAddressUpdatePayload, SocialCheckinPayload, SocialDisconnectNoticePayload,
|
|
SyncPost, VisibilityUpdatePayload, WormQueryPayload, WormResponsePayload, ALPN_V2,
|
|
};
|
|
use crate::storage::Storage;
|
|
use crate::types::{
|
|
DeviceProfile, NodeId, PeerSlotKind, PeerWithAddress, PostId, PostVisibility, ReachMethod,
|
|
SessionReachMethod, SocialRouteEntry, SocialStatus, WormId, WormResult,
|
|
};
|
|
|
|
const MAX_PAYLOAD: usize = 64 * 1024 * 1024; // 64 MB
|
|
|
|
const WORM_FAN_OUT_TIMEOUT_MS: u64 = 500;
|
|
const WORM_BLOOM_TIMEOUT_MS: u64 = 1500;
|
|
const WORM_TOTAL_TIMEOUT_MS: u64 = 3000;
|
|
const WORM_COOLDOWN_MS: i64 = 300_000; // 5 min
|
|
const WORM_DEDUP_EXPIRY_MS: u64 = 10_000; // 10 sec
|
|
const SESSION_IDLE_TIMEOUT_MS: u64 = 300_000; // 5 min
|
|
#[allow(dead_code)]
|
|
const RELAY_COOLDOWN_MS: i64 = 300_000; // 5 min
|
|
const RELAY_INTRO_DEDUP_EXPIRY_MS: u64 = 30_000; // 30 sec
|
|
#[allow(dead_code)]
|
|
const RELAY_INTRO_TIMEOUT_MS: u64 = 15_000; // 15 sec
|
|
const HOLE_PUNCH_TIMEOUT_MS: u64 = 30_000; // 30 sec overall window
|
|
const HOLE_PUNCH_ATTEMPT_MS: u64 = 2_000; // 2 sec per attempt before retry
|
|
/// Max bytes relayed per pipe before closing
|
|
const RELAY_MAX_BYTES: u64 = 50 * 1024 * 1024; // 50 MB
|
|
/// Relay pipe idle timeout
|
|
const RELAY_PIPE_IDLE_MS: u64 = 120_000; // 2 min
|
|
/// How long a preferred peer can be unreachable before being pruned (7 days)
|
|
const PREFERRED_UNREACHABLE_PRUNE_MS: u64 = 7 * 24 * 60 * 60 * 1000;
|
|
/// How long reconnect watchers live before expiry (30 days)
|
|
const WATCHER_EXPIRY_MS: i64 = 30 * 24 * 60 * 60 * 1000;
|
|
/// Max pending introductions per target in 5 minutes
|
|
#[allow(dead_code)]
|
|
const RELAY_TARGET_RATE_LIMIT: usize = 5;
|
|
|
|
/// Grace period before removing disconnected peers from referral list
|
|
const REFERRAL_DISCONNECT_GRACE_MS: u64 = 120_000; // 2 min
|
|
/// Soft cap on referral list size (affects max_uses tiering)
|
|
const REFERRAL_LIST_CAP: usize = 50;
|
|
|
|
/// Zombie connection timeout: no stream activity for this long = dead
|
|
const ZOMBIE_TIMEOUT_MS: u64 = 600_000; // 10 minutes
|
|
|
|
/// Mesh keepalive interval: send a lightweight ping to prevent zombie reaping + NAT timeout
|
|
const MESH_KEEPALIVE_INTERVAL_SECS: u64 = 30;
|
|
|
|
/// Relay introduction identifier for deduplication
|
|
pub type IntroId = [u8; 16];
|
|
|
|
/// Result of initial exchange: accepted or refused with optional redirect peer.
|
|
pub enum ExchangeResult {
|
|
Accepted,
|
|
Refused { redirect: Option<PeerWithAddress> },
|
|
}
|
|
|
|
/// Hole punch: try all addresses in parallel, retrying every HOLE_PUNCH_ATTEMPT_MS
|
|
/// for up to HOLE_PUNCH_TIMEOUT_MS total. Returns the first successful connection
|
|
/// or None if all attempts fail.
|
|
pub(crate) async fn hole_punch_parallel(
|
|
endpoint: &iroh::Endpoint,
|
|
target: &NodeId,
|
|
addresses: &[String],
|
|
) -> Option<iroh::endpoint::Connection> {
|
|
use crate::protocol::ALPN_V2;
|
|
|
|
let addrs: Vec<iroh::EndpointAddr> = addresses
|
|
.iter()
|
|
.filter_map(|addr_str| {
|
|
let sock = normalize_addr(addr_str.parse::<std::net::SocketAddr>().ok()?);
|
|
let eid = iroh::EndpointId::from_bytes(target).ok()?;
|
|
Some(iroh::EndpointAddr::from(eid).with_ip_addr(sock))
|
|
})
|
|
.collect();
|
|
|
|
if addrs.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let deadline = tokio::time::Instant::now()
|
|
+ std::time::Duration::from_millis(HOLE_PUNCH_TIMEOUT_MS);
|
|
let mut attempt = 0u32;
|
|
|
|
while tokio::time::Instant::now() < deadline {
|
|
attempt += 1;
|
|
|
|
// Spawn a connect attempt to every address in parallel
|
|
let mut handles = Vec::new();
|
|
for addr in &addrs {
|
|
let ep = endpoint.clone();
|
|
let a = addr.clone();
|
|
handles.push(tokio::spawn(async move {
|
|
tokio::time::timeout(
|
|
std::time::Duration::from_millis(HOLE_PUNCH_ATTEMPT_MS),
|
|
ep.connect(a, ALPN_V2),
|
|
).await
|
|
}));
|
|
}
|
|
|
|
// Wait for all to finish — return first success
|
|
for handle in handles {
|
|
if let Ok(Ok(Ok(conn))) = handle.await {
|
|
tracing::info!(
|
|
peer = hex::encode(target),
|
|
attempt,
|
|
"Hole punch succeeded"
|
|
);
|
|
return Some(conn);
|
|
}
|
|
}
|
|
|
|
tracing::debug!(
|
|
peer = hex::encode(target),
|
|
attempt,
|
|
"Hole punch attempt failed, retrying"
|
|
);
|
|
}
|
|
|
|
tracing::debug!(peer = hex::encode(target), attempts = attempt, "Hole punch exhausted");
|
|
None
|
|
}
|
|
|
|
/// Timeout for each individual scan connect attempt (200ms → ~20 in-flight at 100/sec)
|
|
const SCAN_CONNECT_TIMEOUT_MS: u64 = 200;
|
|
/// Scan rate: one attempt every 10ms = 100 ports/sec
|
|
const SCAN_INTERVAL_MS: u64 = 10;
|
|
/// How often to punch peer's anchor-observed address during scanning (seconds).
|
|
/// Each punch checks if the peer has opened a firewall port matching our actual port.
|
|
const SCAN_PUNCH_INTERVAL_SECS: u64 = 2;
|
|
/// Maximum scan duration (seconds) — accept the cost for otherwise-impossible connections
|
|
const SCAN_MAX_DURATION_SECS: u64 = 300; // 5 minutes
|
|
|
|
/// Advanced hole punch with port scanning fallback for EDM/port-restricted NAT.
|
|
///
|
|
/// **Role-based behavior** (each side calls this independently):
|
|
///
|
|
/// - **Scanner** (our mapping = EDM or Unknown): Walk outward from peer's anchor-observed
|
|
/// base port at ~100/sec. Each probe opens a firewall entry on our NAT so that when
|
|
/// the peer punches us, their packet can land. Also punch every 2s to peer's observed
|
|
/// address to check if they've opened their port for us.
|
|
///
|
|
/// - **Puncher** (our mapping = EIM): Our port is stable — the peer's scanner will find
|
|
/// us. We just punch every 2s to peer's anchor-observed address, which also keeps our
|
|
/// NAT mapping alive and checks if the peer's scan has opened their firewall for us.
|
|
///
|
|
/// For both-EDM pairs: both sides scan + punch simultaneously.
|
|
pub(crate) async fn hole_punch_with_scanning(
|
|
endpoint: &iroh::Endpoint,
|
|
target: &NodeId,
|
|
addresses: &[String],
|
|
our_profile: crate::types::NatProfile,
|
|
peer_profile: crate::types::NatProfile,
|
|
) -> Option<iroh::endpoint::Connection> {
|
|
// Step 1: Standard hole punch (one quick round to anchor-observed address)
|
|
let quick_result = hole_punch_single(endpoint, target, addresses).await;
|
|
if quick_result.is_some() {
|
|
return quick_result;
|
|
}
|
|
|
|
// Step 2: Decide whether to scan
|
|
if !our_profile.should_try_scanning(&peer_profile) {
|
|
// Neither side is EDM — standard punch is all we can do, try full window
|
|
return hole_punch_parallel(endpoint, target, addresses).await;
|
|
}
|
|
|
|
// Parse anchor-observed address (first in list, injected by relay)
|
|
let observed_addr = addresses.first()
|
|
.and_then(|a| a.parse::<std::net::SocketAddr>().ok())
|
|
.map(|s| normalize_addr(s));
|
|
|
|
let (scan_ip, base_port) = match observed_addr {
|
|
Some(s) => (s.ip(), s.port()),
|
|
None => return None,
|
|
};
|
|
|
|
let eid = match iroh::EndpointId::from_bytes(target).ok() {
|
|
Some(e) => e,
|
|
None => return None,
|
|
};
|
|
|
|
// Determine our role based on mapping type
|
|
let we_scan = our_profile.mapping != crate::types::NatMapping::EndpointIndependent;
|
|
// EIM = stable port, no need to scan. EDM/Unknown = our port changes, need to scan.
|
|
|
|
let role = if we_scan { "scanner+puncher" } else { "puncher" };
|
|
tracing::info!(
|
|
peer = hex::encode(target),
|
|
our_mapping = %our_profile.mapping,
|
|
peer_mapping = %peer_profile.mapping,
|
|
role,
|
|
scan_ip = %scan_ip,
|
|
base_port,
|
|
"Advanced NAT traversal started"
|
|
);
|
|
|
|
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(SCAN_MAX_DURATION_SECS);
|
|
let (found_tx, mut found_rx) = tokio::sync::mpsc::channel::<iroh::endpoint::Connection>(1);
|
|
let mut join_set = tokio::task::JoinSet::new();
|
|
|
|
// Punch address: single anchor-observed address for the peer
|
|
let punch_addr = iroh::EndpointAddr::from(eid).with_ip_addr(
|
|
std::net::SocketAddr::new(scan_ip, base_port)
|
|
);
|
|
|
|
// Set up punch interval (both roles punch every 2s)
|
|
let mut punch_interval = tokio::time::interval(std::time::Duration::from_secs(SCAN_PUNCH_INTERVAL_SECS));
|
|
punch_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
|
// Skip the first tick (we already tried quick punch above)
|
|
punch_interval.tick().await;
|
|
|
|
if we_scan {
|
|
// Scanner role: walk outward from base port at ~100/sec + punch every 2s
|
|
let mut port_iter = PortWalkIter::new(base_port);
|
|
let mut scan_interval = tokio::time::interval(std::time::Duration::from_millis(SCAN_INTERVAL_MS));
|
|
scan_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
|
|
|
let mut ports_tried: u32 = 0;
|
|
let mut last_log_count: u32 = 0;
|
|
|
|
loop {
|
|
tokio::select! {
|
|
// Rate-limited port scan: one attempt per tick (~100/sec)
|
|
_ = scan_interval.tick() => {
|
|
if tokio::time::Instant::now() >= deadline {
|
|
break;
|
|
}
|
|
if let Some(port) = port_iter.next() {
|
|
let ep = endpoint.clone();
|
|
let tx = found_tx.clone();
|
|
let addr = iroh::EndpointAddr::from(eid).with_ip_addr(
|
|
std::net::SocketAddr::new(scan_ip, port)
|
|
);
|
|
join_set.spawn(async move {
|
|
if let Ok(Ok(conn)) = tokio::time::timeout(
|
|
std::time::Duration::from_millis(SCAN_CONNECT_TIMEOUT_MS),
|
|
ep.connect(addr, ALPN_V2),
|
|
).await {
|
|
let _ = tx.send(conn).await;
|
|
}
|
|
});
|
|
ports_tried += 1;
|
|
|
|
// Log progress every 1000 ports
|
|
if ports_tried - last_log_count >= 1000 {
|
|
last_log_count = ports_tried;
|
|
tracing::info!(
|
|
peer = hex::encode(target),
|
|
ports_tried,
|
|
current_offset = port_iter.current_offset(),
|
|
"Port scan progress"
|
|
);
|
|
}
|
|
} else {
|
|
break; // exhausted all valid ports
|
|
}
|
|
}
|
|
|
|
// Every 2s: punch peer's anchor-observed address
|
|
_ = punch_interval.tick() => {
|
|
let ep = endpoint.clone();
|
|
let tx = found_tx.clone();
|
|
let addr = punch_addr.clone();
|
|
join_set.spawn(async move {
|
|
if let Ok(Ok(conn)) = tokio::time::timeout(
|
|
std::time::Duration::from_millis(SCAN_CONNECT_TIMEOUT_MS),
|
|
ep.connect(addr, ALPN_V2),
|
|
).await {
|
|
let _ = tx.send(conn).await;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Success!
|
|
result = found_rx.recv() => {
|
|
if let Some(conn) = result {
|
|
tracing::info!(
|
|
peer = hex::encode(target),
|
|
ports_tried,
|
|
"Advanced NAT: scan+punch succeeded"
|
|
);
|
|
join_set.abort_all();
|
|
return Some(conn);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
join_set.abort_all();
|
|
tracing::info!(peer = hex::encode(target), ports_tried, "Advanced NAT: scan exhausted");
|
|
} else {
|
|
// Puncher-only role (EIM): our port is stable, peer scans to find us.
|
|
// We just punch every 2s to peer's observed address to keep our mapping
|
|
// alive and check if their scan has opened their firewall for us.
|
|
loop {
|
|
tokio::select! {
|
|
_ = punch_interval.tick() => {
|
|
if tokio::time::Instant::now() >= deadline {
|
|
break;
|
|
}
|
|
let ep = endpoint.clone();
|
|
let tx = found_tx.clone();
|
|
let addr = punch_addr.clone();
|
|
join_set.spawn(async move {
|
|
if let Ok(Ok(conn)) = tokio::time::timeout(
|
|
std::time::Duration::from_millis(SCAN_CONNECT_TIMEOUT_MS),
|
|
ep.connect(addr, ALPN_V2),
|
|
).await {
|
|
let _ = tx.send(conn).await;
|
|
}
|
|
});
|
|
}
|
|
|
|
result = found_rx.recv() => {
|
|
if let Some(conn) = result {
|
|
tracing::info!(
|
|
peer = hex::encode(target),
|
|
"Advanced NAT: punch succeeded (peer's scan opened their firewall)"
|
|
);
|
|
join_set.abort_all();
|
|
return Some(conn);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
join_set.abort_all();
|
|
tracing::info!(peer = hex::encode(target), "Advanced NAT: punch-only exhausted (peer may not be scanning)");
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Iterator that walks outward from a base port: base, base+1, base-1, base+2, base-2, ...
|
|
/// Skips ports outside [1, 65535].
|
|
struct PortWalkIter {
|
|
base: u16,
|
|
offset: u32,
|
|
tried_plus: bool, // within current offset, have we tried base+offset?
|
|
}
|
|
|
|
impl PortWalkIter {
|
|
fn new(base: u16) -> Self {
|
|
Self { base, offset: 0, tried_plus: false }
|
|
}
|
|
|
|
fn current_offset(&self) -> u32 {
|
|
self.offset
|
|
}
|
|
}
|
|
|
|
impl Iterator for PortWalkIter {
|
|
type Item = u16;
|
|
|
|
fn next(&mut self) -> Option<u16> {
|
|
// offset 0: just the base port
|
|
if self.offset == 0 {
|
|
self.offset = 1;
|
|
self.tried_plus = false;
|
|
return Some(self.base);
|
|
}
|
|
|
|
// For each offset > 0: try base+offset, then base-offset
|
|
loop {
|
|
if self.offset > 65535 {
|
|
return None; // exhausted
|
|
}
|
|
|
|
if !self.tried_plus {
|
|
self.tried_plus = true;
|
|
let candidate = self.base as u32 + self.offset;
|
|
if candidate <= 65535 {
|
|
return Some(candidate as u16);
|
|
}
|
|
// Fall through to try minus
|
|
}
|
|
|
|
// Try base - offset
|
|
self.tried_plus = false;
|
|
self.offset += 1;
|
|
let prev_offset = self.offset - 1;
|
|
if (self.base as u32) >= prev_offset && self.base as u32 - prev_offset >= 1 {
|
|
return Some((self.base as u32 - prev_offset) as u16);
|
|
}
|
|
// Both sides out of range at this offset, try next
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Quick single punch to the anchor-observed address only (first in list).
|
|
/// Used as the initial attempt before escalating to scanning.
|
|
async fn hole_punch_single(
|
|
endpoint: &iroh::Endpoint,
|
|
target: &NodeId,
|
|
addresses: &[String],
|
|
) -> Option<iroh::endpoint::Connection> {
|
|
let addr = addresses.first()
|
|
.and_then(|a| a.parse::<std::net::SocketAddr>().ok())
|
|
.map(|s| normalize_addr(s))?;
|
|
let eid = iroh::EndpointId::from_bytes(target).ok()?;
|
|
let endpoint_addr = iroh::EndpointAddr::from(eid).with_ip_addr(addr);
|
|
|
|
match tokio::time::timeout(
|
|
std::time::Duration::from_millis(HOLE_PUNCH_ATTEMPT_MS),
|
|
endpoint.connect(endpoint_addr, ALPN_V2),
|
|
).await {
|
|
Ok(Ok(conn)) => {
|
|
tracing::info!(peer = hex::encode(target), "Quick single punch succeeded");
|
|
Some(conn)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
|
|
/// Normalize IPv4-mapped IPv6 addresses (e.g. [::ffff:1.2.3.4]:port) to plain IPv4.
|
|
/// Dual-stack servers report IPv4 peers as mapped-v6 but v4-only clients can't reach them.
|
|
pub fn normalize_addr(addr: std::net::SocketAddr) -> std::net::SocketAddr {
|
|
match addr {
|
|
std::net::SocketAddr::V6(v6) => {
|
|
if let Some(v4) = v6.ip().to_ipv4_mapped() {
|
|
std::net::SocketAddr::new(std::net::IpAddr::V4(v4), v6.port())
|
|
} else {
|
|
addr
|
|
}
|
|
}
|
|
_ => addr,
|
|
}
|
|
}
|
|
|
|
pub struct MeshConnection {
|
|
pub node_id: NodeId,
|
|
pub connection: iroh::endpoint::Connection,
|
|
pub slot_kind: PeerSlotKind,
|
|
pub connected_at: u64,
|
|
/// Remote address as seen from our side of the QUIC connection
|
|
pub remote_addr: Option<std::net::SocketAddr>,
|
|
/// Last time a stream was accepted on this connection (for zombie detection)
|
|
pub last_activity: Arc<AtomicU64>,
|
|
}
|
|
|
|
pub struct SessionConnection {
|
|
pub node_id: NodeId,
|
|
pub connection: iroh::endpoint::Connection,
|
|
pub created_at: u64,
|
|
pub last_active_at: u64,
|
|
pub reach_method: SessionReachMethod,
|
|
/// Remote address as seen from our side of the QUIC connection
|
|
pub remote_addr: Option<std::net::SocketAddr>,
|
|
}
|
|
|
|
pub struct PullSyncStats {
|
|
pub posts_received: usize,
|
|
pub visibility_updates: usize,
|
|
}
|
|
|
|
/// Entry in the anchor's referral list — connection-backed, self-pruning.
|
|
struct ReferralEntry {
|
|
node_id: NodeId,
|
|
addresses: Vec<String>,
|
|
#[allow(dead_code)]
|
|
registered_at: u64,
|
|
use_count: u32,
|
|
disconnected_at: Option<u64>,
|
|
}
|
|
|
|
pub struct ConnectionManager {
|
|
connections: HashMap<NodeId, MeshConnection>,
|
|
endpoint: iroh::Endpoint,
|
|
storage: Arc<Mutex<Storage>>,
|
|
our_node_id: NodeId,
|
|
#[allow(dead_code)]
|
|
is_anchor: Arc<AtomicBool>,
|
|
diff_seq: AtomicU64,
|
|
#[allow(dead_code)]
|
|
secret_seed: [u8; 32],
|
|
blob_store: Arc<BlobStore>,
|
|
/// Dedup map for worm queries: worm_id → timestamp_ms
|
|
seen_worms: HashMap<WormId, u64>,
|
|
/// Last broadcast N1 set (for computing diffs)
|
|
last_n1_set: HashSet<NodeId>,
|
|
/// Last broadcast N2 set (for computing diffs)
|
|
last_n2_set: HashSet<NodeId>,
|
|
/// Max preferred (bilateral) mesh slots
|
|
preferred_slots: usize,
|
|
/// Max local (diverse) mesh slots
|
|
local_slots: usize,
|
|
/// Max wide (bloom-sourced) mesh slots
|
|
wide_slots: usize,
|
|
/// Session connections: short-lived, tracked, separate from mesh slots
|
|
sessions: HashMap<NodeId, SessionConnection>,
|
|
/// Max session slots
|
|
session_slots: usize,
|
|
/// Dedup map for relay introductions: intro_id → timestamp_ms
|
|
seen_intros: HashMap<IntroId, u64>,
|
|
/// Active relay pipe count (we are the intermediary)
|
|
active_relay_pipes: Arc<AtomicU64>,
|
|
/// Max concurrent relay pipes
|
|
max_relay_pipes: usize,
|
|
/// Device profile (for resource limits)
|
|
#[allow(dead_code)]
|
|
device_profile: DeviceProfile,
|
|
/// Peers known to be unreachable directly: node_id → last_failed_at_ms
|
|
/// Learned over the session — cleared on restart, updated on connect success/failure
|
|
unreachable_peers: HashMap<NodeId, u64>,
|
|
/// Anchor-side referral list: connected peers available for referral
|
|
referral_list: HashMap<NodeId, ReferralEntry>,
|
|
/// Channel to signal the growth loop to wake up and seek diverse peers
|
|
growth_tx: Option<tokio::sync::mpsc::Sender<()>>,
|
|
/// Channel to signal the recovery loop when mesh drops below threshold
|
|
recovery_tx: Option<tokio::sync::mpsc::Sender<()>>,
|
|
activity_log: Arc<std::sync::Mutex<ActivityLog>>,
|
|
/// UPnP external address (prepended to self-reported addresses in anchor registration)
|
|
upnp_external_addr: Option<SocketAddr>,
|
|
/// Stable bind address (from --bind flag), used for anchor advertised address
|
|
bind_addr: Option<SocketAddr>,
|
|
/// Our detected NAT type (from STUN probing on startup)
|
|
nat_type: crate::types::NatType,
|
|
/// Our detected NAT mapping (from STUN probing on startup)
|
|
nat_mapping: crate::types::NatMapping,
|
|
/// Our detected NAT filtering (from anchor filter probe, starts Unknown)
|
|
pub(crate) nat_filtering: crate::types::NatFiltering,
|
|
/// Anchor probe: last successful probe timestamp (0 = never)
|
|
last_probe_success_ms: u64,
|
|
/// Anchor probe: consecutive failure count
|
|
probe_failure_streak: u8,
|
|
/// When this ConnectionManager was created
|
|
started_at_ms: u64,
|
|
/// Dedup map for anchor probes: probe_id → timestamp_ms
|
|
seen_probes: HashMap<[u8; 16], u64>,
|
|
/// Whether this node's HTTP server is running and externally reachable
|
|
pub(crate) http_capable: bool,
|
|
/// External HTTP address (ip:port) if known
|
|
pub(crate) http_addr: Option<String>,
|
|
/// Sticky N1 entries: NodeIds to report in N1 share until expiry (ms).
|
|
/// Used to advertise the bootstrap anchor for 24h after isolation recovery.
|
|
sticky_n1: HashMap<NodeId, u64>,
|
|
}
|
|
|
|
impl ConnectionManager {
|
|
pub fn new(
|
|
endpoint: iroh::Endpoint,
|
|
storage: Arc<Mutex<Storage>>,
|
|
our_node_id: NodeId,
|
|
is_anchor: Arc<AtomicBool>,
|
|
secret_seed: [u8; 32],
|
|
blob_store: Arc<BlobStore>,
|
|
profile: DeviceProfile,
|
|
activity_log: Arc<std::sync::Mutex<ActivityLog>>,
|
|
upnp_external_addr: Option<SocketAddr>,
|
|
bind_addr: Option<SocketAddr>,
|
|
nat_type: crate::types::NatType,
|
|
nat_mapping: crate::types::NatMapping,
|
|
) -> Self {
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis() as u64;
|
|
Self {
|
|
connections: HashMap::new(),
|
|
endpoint,
|
|
storage,
|
|
our_node_id,
|
|
is_anchor,
|
|
diff_seq: AtomicU64::new(0),
|
|
secret_seed,
|
|
blob_store,
|
|
seen_worms: HashMap::new(),
|
|
last_n1_set: HashSet::new(),
|
|
last_n2_set: HashSet::new(),
|
|
preferred_slots: profile.preferred_slots(),
|
|
local_slots: profile.local_slots(),
|
|
wide_slots: profile.wide_slots(),
|
|
sessions: HashMap::new(),
|
|
session_slots: profile.session_slots(),
|
|
seen_intros: HashMap::new(),
|
|
active_relay_pipes: Arc::new(AtomicU64::new(0)),
|
|
max_relay_pipes: profile.max_relay_pipes(),
|
|
device_profile: profile,
|
|
unreachable_peers: HashMap::new(),
|
|
referral_list: HashMap::new(),
|
|
growth_tx: None,
|
|
recovery_tx: None,
|
|
activity_log,
|
|
upnp_external_addr,
|
|
bind_addr,
|
|
nat_type,
|
|
nat_mapping,
|
|
// Filtering starts Unknown — even "Public" nodes may only be public on
|
|
// IPv6 while NATed on IPv4. The anchor filter probe determines this reliably.
|
|
nat_filtering: crate::types::NatFiltering::Unknown,
|
|
last_probe_success_ms: 0,
|
|
probe_failure_streak: 0,
|
|
started_at_ms: now,
|
|
seen_probes: HashMap::new(),
|
|
http_capable: false,
|
|
http_addr: None,
|
|
sticky_n1: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Our detected NAT type
|
|
pub fn nat_type(&self) -> crate::types::NatType {
|
|
self.nat_type
|
|
}
|
|
|
|
/// Our detected NAT mapping
|
|
pub fn nat_mapping(&self) -> crate::types::NatMapping {
|
|
self.nat_mapping
|
|
}
|
|
|
|
/// Our NAT profile (mapping + filtering)
|
|
pub fn our_nat_profile(&self) -> crate::types::NatProfile {
|
|
crate::types::NatProfile::new(self.nat_mapping, self.nat_filtering)
|
|
}
|
|
|
|
/// Whether this node is a candidate for anchor status (has UPnP/public, enough connections, uptime)
|
|
pub fn is_anchor_candidate(&self) -> bool {
|
|
let now = now_ms();
|
|
// Must have UPnP mapping or public IPv6
|
|
let has_public_addr = self.upnp_external_addr.is_some()
|
|
|| self.endpoint.addr().ip_addrs().any(|s| crate::network::is_publicly_routable(&s));
|
|
if !has_public_addr {
|
|
return false;
|
|
}
|
|
// Must have enough connections (at least 50)
|
|
if self.connections.len() < 50 {
|
|
return false;
|
|
}
|
|
// Must have been running for at least 2 hours
|
|
let two_hours_ms = 2 * 60 * 60 * 1000;
|
|
if now.saturating_sub(self.started_at_ms) < two_hours_ms {
|
|
return false;
|
|
}
|
|
// Not mobile
|
|
if self.device_profile == crate::types::DeviceProfile::Mobile {
|
|
return false;
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Whether an anchor probe is due (candidate minus probe-success check, last probe > 30 min ago)
|
|
pub fn probe_due(&self) -> bool {
|
|
let now = now_ms();
|
|
let has_public_addr = self.upnp_external_addr.is_some()
|
|
|| self.endpoint.addr().ip_addrs().any(|s| crate::network::is_publicly_routable(&s));
|
|
if !has_public_addr {
|
|
return false;
|
|
}
|
|
if self.connections.len() < 50 {
|
|
return false;
|
|
}
|
|
let two_hours_ms = 2 * 60 * 60 * 1000;
|
|
if now.saturating_sub(self.started_at_ms) < two_hours_ms {
|
|
return false;
|
|
}
|
|
if self.device_profile == crate::types::DeviceProfile::Mobile {
|
|
return false;
|
|
}
|
|
// Last probe must be > 30 min ago
|
|
let thirty_min_ms = 30 * 60 * 1000;
|
|
now.saturating_sub(self.last_probe_success_ms) > thirty_min_ms
|
|
}
|
|
|
|
/// Initiate an anchor self-verification probe.
|
|
/// Selects a stranger from N2, sends probe request via the reporter.
|
|
/// Returns true if probe succeeded (we're reachable).
|
|
pub async fn initiate_anchor_probe(&mut self) -> anyhow::Result<bool> {
|
|
use crate::protocol::{
|
|
AnchorProbeRequestPayload, AnchorProbeResultPayload,
|
|
MessageType, read_message_type, read_payload, write_typed_message,
|
|
};
|
|
|
|
let our_connections: HashSet<NodeId> = self.connections.keys().copied().collect();
|
|
let (witness, reporter) = {
|
|
let s = self.storage.lock().await;
|
|
match s.random_n2_stranger(&our_connections)? {
|
|
Some(pair) => pair,
|
|
None => {
|
|
debug!("No N2 stranger available for anchor probe");
|
|
return Ok(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Build our external address for the probe target
|
|
let target_addr = match self.build_anchor_advertised_addr() {
|
|
Some(addr) => addr,
|
|
None => {
|
|
debug!("No advertised address for anchor probe");
|
|
return Ok(false);
|
|
}
|
|
};
|
|
|
|
// Our globally-routable addresses for the witness to deliver result
|
|
let mut candidate_addrs: Vec<String> = self.endpoint.addr().ip_addrs()
|
|
.filter(|s| crate::network::is_publicly_routable(s))
|
|
.map(|s| s.to_string())
|
|
.collect();
|
|
if let Some(ref ext) = self.upnp_external_addr {
|
|
let ext_str = ext.to_string();
|
|
if !candidate_addrs.contains(&ext_str) {
|
|
candidate_addrs.insert(0, ext_str);
|
|
}
|
|
}
|
|
|
|
let probe_id: [u8; 16] = {
|
|
use rand::Rng;
|
|
let mut id = [0u8; 16];
|
|
rand::rng().fill(&mut id);
|
|
id
|
|
};
|
|
|
|
let payload = AnchorProbeRequestPayload {
|
|
target_addr,
|
|
witness,
|
|
candidate: self.our_node_id,
|
|
candidate_addresses: candidate_addrs,
|
|
probe_id,
|
|
};
|
|
|
|
// Send to reporter via bi-stream
|
|
let reporter_conn = match self.connections.get(&reporter) {
|
|
Some(mc) => mc.connection.clone(),
|
|
None => {
|
|
debug!("Reporter not connected for anchor probe");
|
|
return Ok(false);
|
|
}
|
|
};
|
|
|
|
let result = tokio::time::timeout(
|
|
std::time::Duration::from_secs(20),
|
|
async {
|
|
let (mut send, mut recv) = reporter_conn.open_bi().await?;
|
|
write_typed_message(&mut send, MessageType::AnchorProbeRequest, &payload).await?;
|
|
send.finish()?;
|
|
|
|
let msg_type = read_message_type(&mut recv).await?;
|
|
if msg_type != MessageType::AnchorProbeResult {
|
|
anyhow::bail!("expected AnchorProbeResult, got {:?}", msg_type);
|
|
}
|
|
let result: AnchorProbeResultPayload = read_payload(&mut recv, 4096).await?;
|
|
Ok::<_, anyhow::Error>(result)
|
|
}
|
|
).await;
|
|
|
|
match result {
|
|
Ok(Ok(probe_result)) => {
|
|
if probe_result.reachable {
|
|
self.last_probe_success_ms = now_ms();
|
|
self.probe_failure_streak = 0;
|
|
self.log_activity(ActivityLevel::Info, ActivityCategory::Anchor,
|
|
"Anchor probe succeeded — confirmed reachable".to_string(), None);
|
|
info!("Anchor probe succeeded — confirmed reachable");
|
|
Ok(true)
|
|
} else {
|
|
self.probe_failure_streak += 1;
|
|
self.log_activity(ActivityLevel::Warn, ActivityCategory::Anchor,
|
|
format!("Anchor probe failed (streak: {})", self.probe_failure_streak), None);
|
|
warn!("Anchor probe failed (streak: {})", self.probe_failure_streak);
|
|
if self.probe_failure_streak >= 2 {
|
|
self.is_anchor.store(false, Ordering::Relaxed);
|
|
self.log_activity(ActivityLevel::Warn, ActivityCategory::Anchor,
|
|
"Anchor status revoked (2 consecutive failures)".to_string(), None);
|
|
warn!("Anchor status revoked (2 consecutive probe failures)");
|
|
}
|
|
Ok(false)
|
|
}
|
|
}
|
|
Ok(Err(e)) => {
|
|
debug!(error = %e, "Anchor probe communication error");
|
|
Ok(false)
|
|
}
|
|
Err(_) => {
|
|
self.probe_failure_streak += 1;
|
|
self.log_activity(ActivityLevel::Warn, ActivityCategory::Anchor,
|
|
format!("Anchor probe timed out (streak: {})", self.probe_failure_streak), None);
|
|
if self.probe_failure_streak >= 2 {
|
|
self.is_anchor.store(false, Ordering::Relaxed);
|
|
self.log_activity(ActivityLevel::Warn, ActivityCategory::Anchor,
|
|
"Anchor status revoked (2 consecutive failures)".to_string(), None);
|
|
warn!("Anchor status revoked (2 consecutive probe failures)");
|
|
}
|
|
Ok(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle an incoming AnchorProbeRequest — either as reporter (forward to witness) or as witness (cold connect)
|
|
pub async fn handle_anchor_probe_request(
|
|
&mut self,
|
|
payload: crate::protocol::AnchorProbeRequestPayload,
|
|
mut send: iroh::endpoint::SendStream,
|
|
) -> anyhow::Result<()> {
|
|
use crate::protocol::{
|
|
AnchorProbeResultPayload, MessageType, read_message_type, read_payload,
|
|
write_typed_message,
|
|
};
|
|
|
|
let now = now_ms();
|
|
|
|
// Dedup check
|
|
if let Some(&seen_at) = self.seen_probes.get(&payload.probe_id) {
|
|
if now - seen_at < 30_000 {
|
|
let result = AnchorProbeResultPayload {
|
|
probe_id: payload.probe_id,
|
|
reachable: false,
|
|
observed_addr: None,
|
|
};
|
|
write_typed_message(&mut send, MessageType::AnchorProbeResult, &result).await?;
|
|
send.finish()?;
|
|
return Ok(());
|
|
}
|
|
}
|
|
self.seen_probes.insert(payload.probe_id, now);
|
|
|
|
// Are we the WITNESS? (witness == our_node_id)
|
|
if payload.witness == self.our_node_id {
|
|
info!(
|
|
candidate = hex::encode(payload.candidate),
|
|
target_addr = %payload.target_addr,
|
|
"Anchor probe: we are witness, cold connecting"
|
|
);
|
|
|
|
// Raw QUIC connect to target_addr, 15s timeout. NO cascade, NO hole punch.
|
|
let reachable = match payload.target_addr.parse::<std::net::SocketAddr>() {
|
|
Ok(sock_addr) => {
|
|
let eid = match iroh::EndpointId::from_bytes(&payload.candidate) {
|
|
Ok(eid) => eid,
|
|
Err(_) => {
|
|
let result = AnchorProbeResultPayload {
|
|
probe_id: payload.probe_id,
|
|
reachable: false,
|
|
observed_addr: None,
|
|
};
|
|
write_typed_message(&mut send, MessageType::AnchorProbeResult, &result).await?;
|
|
send.finish()?;
|
|
return Ok(());
|
|
}
|
|
};
|
|
let addr = iroh::EndpointAddr::from(eid).with_ip_addr(sock_addr);
|
|
match tokio::time::timeout(
|
|
std::time::Duration::from_secs(15),
|
|
self.endpoint.connect(addr, ALPN_V2),
|
|
).await {
|
|
Ok(Ok(_conn)) => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
Err(_) => false,
|
|
};
|
|
|
|
let result = AnchorProbeResultPayload {
|
|
probe_id: payload.probe_id,
|
|
reachable,
|
|
observed_addr: None,
|
|
};
|
|
|
|
// Send result back through the bi-stream (back through reporter)
|
|
write_typed_message(&mut send, MessageType::AnchorProbeResult, &result).await?;
|
|
send.finish()?;
|
|
return Ok(());
|
|
}
|
|
|
|
// We are the REPORTER — forward to the witness
|
|
if let Some(witness_conn) = self.connections.get(&payload.witness) {
|
|
let witness_connection = witness_conn.connection.clone();
|
|
let probe_id = payload.probe_id;
|
|
|
|
// Forward the probe request to the witness
|
|
let forward_result = tokio::time::timeout(
|
|
std::time::Duration::from_secs(20),
|
|
async {
|
|
let (mut w_send, mut w_recv) = witness_connection.open_bi().await?;
|
|
write_typed_message(&mut w_send, MessageType::AnchorProbeRequest, &payload).await?;
|
|
w_send.finish()?;
|
|
let msg_type = read_message_type(&mut w_recv).await?;
|
|
if msg_type != MessageType::AnchorProbeResult {
|
|
anyhow::bail!("expected AnchorProbeResult from witness, got {:?}", msg_type);
|
|
}
|
|
let result: AnchorProbeResultPayload = read_payload(&mut w_recv, 4096).await?;
|
|
Ok::<_, anyhow::Error>(result)
|
|
}
|
|
).await;
|
|
|
|
let result = match forward_result {
|
|
Ok(Ok(r)) => r,
|
|
_ => AnchorProbeResultPayload {
|
|
probe_id,
|
|
reachable: false,
|
|
observed_addr: None,
|
|
},
|
|
};
|
|
|
|
write_typed_message(&mut send, MessageType::AnchorProbeResult, &result).await?;
|
|
send.finish()?;
|
|
} else {
|
|
// Witness not connected to us
|
|
let result = AnchorProbeResultPayload {
|
|
probe_id: payload.probe_id,
|
|
reachable: false,
|
|
observed_addr: None,
|
|
};
|
|
write_typed_message(&mut send, MessageType::AnchorProbeResult, &result).await?;
|
|
send.finish()?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Build the stable advertised address for this anchor node.
|
|
/// Returns None if not an anchor or no suitable address found.
|
|
pub fn build_anchor_advertised_addr(&self) -> Option<String> {
|
|
if !self.is_anchor.load(Ordering::Relaxed) {
|
|
return None;
|
|
}
|
|
// Priority: UPnP external addr (has public IP + correct port)
|
|
if let Some(ref ext) = self.upnp_external_addr {
|
|
return Some(ext.to_string());
|
|
}
|
|
// If --bind was used, combine bind port with first public IP
|
|
if let Some(bind) = self.bind_addr {
|
|
let port = bind.port();
|
|
for sa in self.endpoint.addr().ip_addrs() {
|
|
if crate::network::is_publicly_routable(&sa) {
|
|
return Some(SocketAddr::new(sa.ip(), port).to_string());
|
|
}
|
|
}
|
|
}
|
|
// Fallback: use first publicly-routable bound address (e.g. public IPv6)
|
|
for sa in self.endpoint.bound_sockets() {
|
|
if crate::network::is_publicly_routable(&sa) {
|
|
return Some(sa.to_string());
|
|
}
|
|
}
|
|
for sa in self.endpoint.addr().ip_addrs() {
|
|
if crate::network::is_publicly_routable(&sa) {
|
|
return Some(sa.to_string());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub(crate) fn log_activity(&self, level: ActivityLevel, cat: ActivityCategory, msg: String, peer: Option<NodeId>) {
|
|
if let Ok(mut log) = self.activity_log.try_lock() {
|
|
log.log(level, cat, msg, peer);
|
|
}
|
|
}
|
|
|
|
/// Public accessor for logging from static methods that hold a conn_mgr lock.
|
|
pub fn log_activity_pub(&self, level: ActivityLevel, cat: ActivityCategory, msg: String, peer: Option<NodeId>) {
|
|
self.log_activity(level, cat, msg, peer);
|
|
}
|
|
|
|
/// Set the growth loop signal sender.
|
|
pub fn set_growth_tx(&mut self, tx: tokio::sync::mpsc::Sender<()>) {
|
|
self.growth_tx = Some(tx);
|
|
}
|
|
|
|
/// Signal the growth loop to wake up and seek diverse peers.
|
|
/// Non-blocking: drops the signal if the channel is full (coalescing).
|
|
pub fn notify_growth(&self) {
|
|
if let Some(tx) = &self.growth_tx {
|
|
let _ = tx.try_send(());
|
|
}
|
|
}
|
|
|
|
/// Set the recovery loop signal sender.
|
|
pub fn set_recovery_tx(&mut self, tx: tokio::sync::mpsc::Sender<()>) {
|
|
self.recovery_tx = Some(tx);
|
|
}
|
|
|
|
/// Signal the recovery loop when mesh is critically low.
|
|
/// Non-blocking: drops the signal if the channel is full (coalescing).
|
|
pub fn notify_recovery(&self) {
|
|
if let Some(tx) = &self.recovery_tx {
|
|
let _ = tx.try_send(());
|
|
}
|
|
}
|
|
|
|
/// Score N2 candidates for growth loop diversity selection.
|
|
/// Returns candidates sorted by diversity score (highest first), excluding
|
|
/// already-connected peers, self, and unreachable peers.
|
|
pub async fn score_n2_candidates(&self) -> Vec<(NodeId, f64)> {
|
|
let candidates = {
|
|
let storage = self.storage.lock().await;
|
|
storage.score_n2_candidates_batch().unwrap_or_default()
|
|
};
|
|
|
|
let mut scored: Vec<(NodeId, f64)> = candidates
|
|
.into_iter()
|
|
.filter(|(nid, _, _)| {
|
|
*nid != self.our_node_id
|
|
&& !self.connections.contains_key(nid)
|
|
&& !self.is_likely_unreachable(nid)
|
|
})
|
|
.map(|(nid, reporter_count, in_n3)| {
|
|
let score = 1.0 / (reporter_count as f64)
|
|
+ if !in_n3 { 0.3 } else { 0.0 };
|
|
(nid, score)
|
|
})
|
|
.collect();
|
|
|
|
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
|
scored
|
|
}
|
|
|
|
/// How many local slots are available for the growth loop to fill.
|
|
pub fn available_local_slots(&self) -> usize {
|
|
let local_count = self.count_kind(PeerSlotKind::Local);
|
|
self.local_slots.saturating_sub(local_count)
|
|
}
|
|
|
|
/// Accept an incoming connection, returning false if slots are full.
|
|
pub fn accept_connection(
|
|
&mut self,
|
|
conn: iroh::endpoint::Connection,
|
|
remote_node_id: NodeId,
|
|
remote_addr: Option<std::net::SocketAddr>,
|
|
) -> bool {
|
|
if self.connections.contains_key(&remote_node_id) {
|
|
debug!(peer = hex::encode(remote_node_id), "Replacing existing connection");
|
|
}
|
|
|
|
let total_slots = self.preferred_slots + self.local_slots + self.wide_slots;
|
|
let total = self.connections.len();
|
|
if total >= total_slots && !self.connections.contains_key(&remote_node_id) {
|
|
debug!(peer = hex::encode(remote_node_id), "Slots full, rejecting");
|
|
return false;
|
|
}
|
|
|
|
let now = now_ms();
|
|
let slot_kind = if self.count_kind(PeerSlotKind::Local) < self.local_slots {
|
|
PeerSlotKind::Local
|
|
} else {
|
|
PeerSlotKind::Wide
|
|
};
|
|
|
|
self.connections.insert(
|
|
remote_node_id,
|
|
MeshConnection {
|
|
node_id: remote_node_id,
|
|
connection: conn,
|
|
slot_kind,
|
|
connected_at: now,
|
|
remote_addr: remote_addr.map(normalize_addr),
|
|
last_activity: Arc::new(AtomicU64::new(now)),
|
|
},
|
|
);
|
|
|
|
// If peer was on referral list and reconnected, clear disconnected_at
|
|
self.mark_referral_reconnected(&remote_node_id);
|
|
|
|
true
|
|
}
|
|
|
|
/// Register an already-established connection into the mesh.
|
|
/// The QUIC connect must happen OUTSIDE the conn_mgr lock to avoid blocking
|
|
/// all other tasks (diff cycle, accept loop, keepalive, rebalance) during
|
|
/// the potentially long connection timeout.
|
|
pub async fn register_connection(
|
|
&mut self,
|
|
peer_id: NodeId,
|
|
conn: iroh::endpoint::Connection,
|
|
addrs: &[std::net::SocketAddr],
|
|
slot_kind: PeerSlotKind,
|
|
) {
|
|
let now = now_ms();
|
|
let connect_addr = addrs.first().copied();
|
|
|
|
// Direct connect succeeded — mark peer as directly reachable
|
|
self.mark_reachable(&peer_id);
|
|
|
|
self.connections.insert(
|
|
peer_id,
|
|
MeshConnection {
|
|
node_id: peer_id,
|
|
connection: conn,
|
|
slot_kind,
|
|
connected_at: now,
|
|
remote_addr: connect_addr.map(normalize_addr),
|
|
last_activity: Arc::new(AtomicU64::new(now)),
|
|
},
|
|
);
|
|
|
|
// If peer was on referral list and reconnected, clear disconnected_at
|
|
self.mark_referral_reconnected(&peer_id);
|
|
|
|
// Persist address to peers table so it survives restart
|
|
if !addrs.is_empty() {
|
|
let storage = self.storage.lock().await;
|
|
let _ = storage.upsert_peer(&peer_id, addrs, None);
|
|
drop(storage);
|
|
}
|
|
|
|
// Record in mesh_peers table + touch social route
|
|
{
|
|
let storage = self.storage.lock().await;
|
|
let _ = storage.add_mesh_peer(&peer_id, slot_kind, 0);
|
|
if storage.has_social_route(&peer_id).unwrap_or(false) {
|
|
let _ = storage.touch_social_route_connect(&peer_id, addrs, ReachMethod::Direct);
|
|
// Gather watcher data before dropping storage
|
|
let watchers = storage.get_reconnect_watchers(&peer_id).unwrap_or_default();
|
|
let route = storage.get_social_route(&peer_id).ok().flatten();
|
|
let _ = storage.clear_reconnect_watchers(&peer_id);
|
|
drop(storage);
|
|
// Notify watchers asynchronously (no storage reference held)
|
|
if !watchers.is_empty() {
|
|
if let Some(route) = route {
|
|
self.notify_watchers(watchers, &peer_id, &route).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
info!(peer = hex::encode(peer_id), kind = %slot_kind, "Connected to peer");
|
|
}
|
|
|
|
/// Establish an outgoing mesh connection with a 15s timeout on the QUIC connect.
|
|
/// Used by rebalance_slots() and reconnect_preferred() which hold &mut self.
|
|
/// Note: holds the conn_mgr lock during the connect. For lock-free connecting,
|
|
/// use Network::connect_to_peer() which connects outside the lock.
|
|
pub async fn connect_to(
|
|
&mut self,
|
|
peer_id: NodeId,
|
|
addr: iroh::EndpointAddr,
|
|
slot_kind: PeerSlotKind,
|
|
) -> anyhow::Result<()> {
|
|
if self.connections.contains_key(&peer_id) {
|
|
return Ok(()); // Already connected
|
|
}
|
|
|
|
let addrs: Vec<std::net::SocketAddr> = addr.ip_addrs().copied().collect();
|
|
if !addrs.is_empty() {
|
|
let storage = self.storage.lock().await;
|
|
let _ = storage.upsert_peer(&peer_id, &addrs, None);
|
|
}
|
|
|
|
// 15s timeout to limit lock contention (QUIC default can be 60+s)
|
|
let conn = tokio::time::timeout(
|
|
std::time::Duration::from_secs(15),
|
|
self.endpoint.connect(addr, ALPN_V2),
|
|
).await
|
|
.map_err(|_| anyhow::anyhow!("connect timed out (15s)"))?
|
|
.map_err(|e| anyhow::anyhow!("connect failed: {e}"))?;
|
|
|
|
self.register_connection(peer_id, conn, &addrs, slot_kind).await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Do the initial exchange after connecting: N1/N2 node lists + profile + deletes + peer addresses (both directions).
|
|
pub async fn do_initial_exchange(
|
|
&self,
|
|
conn: &iroh::endpoint::Connection,
|
|
remote_node_id: NodeId,
|
|
) -> anyhow::Result<()> {
|
|
// Build our payload
|
|
let our_payload = {
|
|
let storage = self.storage.lock().await;
|
|
let n1 = storage.build_n1_share()?;
|
|
let n2 = storage.build_n2_share()?;
|
|
let profile = storage.get_profile(&self.our_node_id)?;
|
|
let deletes = storage.list_delete_records()?;
|
|
let post_ids = storage.list_post_ids()?;
|
|
let peer_addresses = storage.build_peer_addresses_for(&self.our_node_id)?;
|
|
let our_profile = crate::types::NatProfile::from_nat_type(self.nat_type);
|
|
InitialExchangePayload {
|
|
n1_node_ids: n1,
|
|
n2_node_ids: n2,
|
|
profile,
|
|
deletes,
|
|
post_ids,
|
|
peer_addresses,
|
|
anchor_addr: self.build_anchor_advertised_addr(),
|
|
your_observed_addr: None,
|
|
nat_type: Some(self.nat_type.to_string()),
|
|
nat_mapping: Some(our_profile.mapping.to_string()),
|
|
nat_filtering: Some(our_profile.filtering.to_string()),
|
|
http_capable: self.http_capable,
|
|
http_addr: self.http_addr.clone(),
|
|
}
|
|
};
|
|
|
|
// Open bi-stream for initial exchange
|
|
let (mut send, mut recv) = conn.open_bi().await?;
|
|
|
|
// Send our payload
|
|
write_typed_message(&mut send, MessageType::InitialExchange, &our_payload).await?;
|
|
send.finish()?;
|
|
|
|
// Read their payload
|
|
let msg_type = read_message_type(&mut recv).await?;
|
|
if msg_type != MessageType::InitialExchange {
|
|
anyhow::bail!("expected InitialExchange, got {:?}", msg_type);
|
|
}
|
|
let their_payload: InitialExchangePayload = read_payload(&mut recv, MAX_PAYLOAD).await?;
|
|
|
|
// Process their data
|
|
let storage = self.storage.lock().await;
|
|
|
|
// Their N1 → our N2 (tagged to this reporter)
|
|
// Filter out our own ID and already-connected peers (they'd waste candidate slots)
|
|
let filtered_n1: Vec<NodeId> = their_payload.n1_node_ids.iter()
|
|
.filter(|nid| **nid != self.our_node_id && !self.connections.contains_key(*nid))
|
|
.copied()
|
|
.collect();
|
|
storage.set_peer_n1(&remote_node_id, &filtered_n1)?;
|
|
debug!(peer = hex::encode(remote_node_id), raw = their_payload.n1_node_ids.len(), stored = filtered_n1.len(), "Stored peer N1 as our N2 (filtered self+mesh)");
|
|
|
|
// Their N2 → our N3 (tagged to this reporter)
|
|
let filtered_n2: Vec<NodeId> = their_payload.n2_node_ids.iter()
|
|
.filter(|nid| **nid != self.our_node_id)
|
|
.copied()
|
|
.collect();
|
|
storage.set_peer_n2(&remote_node_id, &filtered_n2)?;
|
|
debug!(peer = hex::encode(remote_node_id), raw = their_payload.n2_node_ids.len(), stored = filtered_n2.len(), "Stored peer N2 as our N3 (filtered self)");
|
|
|
|
// Store their profile
|
|
if let Some(profile) = their_payload.profile {
|
|
let _ = storage.store_profile(&profile);
|
|
}
|
|
|
|
// Process delete records
|
|
for dr in their_payload.deletes {
|
|
if crypto::verify_delete_signature(&dr.author, &dr.post_id, &dr.signature) {
|
|
let _ = storage.store_delete(&dr);
|
|
let _ = storage.apply_delete(&dr);
|
|
}
|
|
}
|
|
|
|
// Store their peer_addresses (N+10:Addresses)
|
|
for pa in &their_payload.peer_addresses {
|
|
if let Ok(nid) = crate::parse_node_id_hex(&pa.n) {
|
|
let addrs: Vec<std::net::SocketAddr> = pa.a.iter().filter_map(|a| a.parse().ok()).collect();
|
|
if !addrs.is_empty() {
|
|
let _ = storage.upsert_peer(&nid, &addrs, None);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Record replicas from overlapping post_ids
|
|
let our_post_ids: HashSet<[u8; 32]> = storage.list_post_ids()?.into_iter().collect();
|
|
for pid in &their_payload.post_ids {
|
|
if our_post_ids.contains(pid) {
|
|
let _ = storage.record_replica(pid, &remote_node_id);
|
|
}
|
|
}
|
|
|
|
// Process anchor's advertised address
|
|
if let Some(ref anchor_addr_str) = their_payload.anchor_addr {
|
|
if let Ok(sock) = anchor_addr_str.parse::<std::net::SocketAddr>() {
|
|
let _ = storage.upsert_known_anchor(&remote_node_id, &[sock]);
|
|
let _ = storage.upsert_peer(&remote_node_id, &[sock], None);
|
|
info!(peer = hex::encode(remote_node_id), addr = %sock, "Stored anchor's advertised address");
|
|
}
|
|
}
|
|
|
|
// Log observed address (STUN-like feedback)
|
|
if let Some(ref observed) = their_payload.your_observed_addr {
|
|
info!(observed_addr = %observed, reporter = hex::encode(remote_node_id), "Peer reports our address as");
|
|
}
|
|
|
|
// Store peer's NAT type
|
|
if let Some(ref nat_str) = their_payload.nat_type {
|
|
let nat = crate::types::NatType::from_str_label(nat_str);
|
|
let _ = storage.set_peer_nat_type(&remote_node_id, nat);
|
|
}
|
|
|
|
// Store peer's NAT profile (mapping + filtering) if provided
|
|
if their_payload.nat_mapping.is_some() || their_payload.nat_filtering.is_some() {
|
|
let mapping = their_payload.nat_mapping.as_deref()
|
|
.map(crate::types::NatMapping::from_str_label)
|
|
.unwrap_or(crate::types::NatMapping::Unknown);
|
|
let filtering = their_payload.nat_filtering.as_deref()
|
|
.map(crate::types::NatFiltering::from_str_label)
|
|
.unwrap_or(crate::types::NatFiltering::Unknown);
|
|
let profile = crate::types::NatProfile::new(mapping, filtering);
|
|
let _ = storage.set_peer_nat_profile(&remote_node_id, &profile);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Handle the responder side of initial exchange.
|
|
pub async fn handle_initial_exchange(
|
|
&self,
|
|
mut send: iroh::endpoint::SendStream,
|
|
mut recv: iroh::endpoint::RecvStream,
|
|
remote_node_id: NodeId,
|
|
) -> anyhow::Result<()> {
|
|
// Read their payload (message type byte already consumed by caller)
|
|
let their_payload: InitialExchangePayload = read_payload(&mut recv, MAX_PAYLOAD).await?;
|
|
|
|
// Build and send our payload
|
|
let our_payload = {
|
|
let storage = self.storage.lock().await;
|
|
let n1 = storage.build_n1_share()?;
|
|
let n2 = storage.build_n2_share()?;
|
|
let profile = storage.get_profile(&self.our_node_id)?;
|
|
let deletes = storage.list_delete_records()?;
|
|
let post_ids = storage.list_post_ids()?;
|
|
let peer_addresses = storage.build_peer_addresses_for(&self.our_node_id)?;
|
|
let our_profile = crate::types::NatProfile::from_nat_type(self.nat_type);
|
|
InitialExchangePayload {
|
|
n1_node_ids: n1,
|
|
n2_node_ids: n2,
|
|
profile,
|
|
deletes,
|
|
post_ids,
|
|
peer_addresses,
|
|
anchor_addr: self.build_anchor_advertised_addr(),
|
|
your_observed_addr: None, // no remote_addr available in this method
|
|
nat_type: Some(self.nat_type.to_string()),
|
|
nat_mapping: Some(our_profile.mapping.to_string()),
|
|
nat_filtering: Some(our_profile.filtering.to_string()),
|
|
http_capable: self.http_capable,
|
|
http_addr: self.http_addr.clone(),
|
|
}
|
|
};
|
|
|
|
write_typed_message(&mut send, MessageType::InitialExchange, &our_payload).await?;
|
|
send.finish()?;
|
|
|
|
// Process their data
|
|
let storage = self.storage.lock().await;
|
|
|
|
// Their N1 → our N2 (filter out self + already-connected peers)
|
|
let filtered_n1: Vec<NodeId> = their_payload.n1_node_ids.iter()
|
|
.filter(|nid| **nid != self.our_node_id && !self.connections.contains_key(*nid))
|
|
.copied()
|
|
.collect();
|
|
storage.set_peer_n1(&remote_node_id, &filtered_n1)?;
|
|
debug!(peer = hex::encode(remote_node_id), raw = their_payload.n1_node_ids.len(), stored = filtered_n1.len(), "Stored peer N1 as our N2 (filtered self+mesh)");
|
|
|
|
// Their N2 → our N3 (filter out self)
|
|
let filtered_n2: Vec<NodeId> = their_payload.n2_node_ids.iter()
|
|
.filter(|nid| **nid != self.our_node_id)
|
|
.copied()
|
|
.collect();
|
|
storage.set_peer_n2(&remote_node_id, &filtered_n2)?;
|
|
|
|
if let Some(profile) = their_payload.profile {
|
|
let _ = storage.store_profile(&profile);
|
|
}
|
|
|
|
for dr in their_payload.deletes {
|
|
if crypto::verify_delete_signature(&dr.author, &dr.post_id, &dr.signature) {
|
|
let _ = storage.store_delete(&dr);
|
|
let _ = storage.apply_delete(&dr);
|
|
}
|
|
}
|
|
|
|
// Store their peer_addresses (N+10:Addresses)
|
|
for pa in &their_payload.peer_addresses {
|
|
if let Ok(nid) = crate::parse_node_id_hex(&pa.n) {
|
|
let addrs: Vec<std::net::SocketAddr> = pa.a.iter().filter_map(|a| a.parse().ok()).collect();
|
|
if !addrs.is_empty() {
|
|
let _ = storage.upsert_peer(&nid, &addrs, None);
|
|
}
|
|
}
|
|
}
|
|
|
|
let our_post_ids: HashSet<[u8; 32]> = storage.list_post_ids()?.into_iter().collect();
|
|
for pid in &their_payload.post_ids {
|
|
if our_post_ids.contains(pid) {
|
|
let _ = storage.record_replica(pid, &remote_node_id);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Compute N1/N2 changes and broadcast NodeListUpdate to all connected peers.
|
|
pub async fn broadcast_routing_diff(&mut self) -> anyhow::Result<usize> {
|
|
let seq = self.diff_seq.fetch_add(1, Ordering::Relaxed) + 1;
|
|
|
|
let (current_n1, current_n2) = {
|
|
let storage = self.storage.lock().await;
|
|
let n1: HashSet<NodeId> = storage.build_n1_share()?.into_iter().collect();
|
|
let n2: HashSet<NodeId> = storage.build_n2_share()?.into_iter().collect();
|
|
(n1, n2)
|
|
};
|
|
|
|
let n1_added: Vec<NodeId> = current_n1.difference(&self.last_n1_set).copied().collect();
|
|
let n1_removed: Vec<NodeId> = self.last_n1_set.difference(¤t_n1).copied().collect();
|
|
let n2_added: Vec<NodeId> = current_n2.difference(&self.last_n2_set).copied().collect();
|
|
let n2_removed: Vec<NodeId> = self.last_n2_set.difference(¤t_n2).copied().collect();
|
|
|
|
if n1_added.is_empty() && n1_removed.is_empty() && n2_added.is_empty() && n2_removed.is_empty() {
|
|
return Ok(0);
|
|
}
|
|
|
|
let payload = NodeListUpdatePayload {
|
|
seq,
|
|
n1_added,
|
|
n1_removed,
|
|
n2_added,
|
|
n2_removed,
|
|
};
|
|
let mut sent_count = 0;
|
|
|
|
for (peer_id, pc) in &self.connections {
|
|
let result = async {
|
|
let mut send = pc.connection.open_uni().await?;
|
|
write_typed_message(&mut send, MessageType::NodeListUpdate, &payload).await?;
|
|
send.finish()?;
|
|
anyhow::Ok(())
|
|
}
|
|
.await;
|
|
|
|
match result {
|
|
Ok(()) => sent_count += 1,
|
|
Err(e) => {
|
|
debug!(
|
|
peer = hex::encode(peer_id),
|
|
error = %e,
|
|
"Failed to send node list update"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update last sets
|
|
self.last_n1_set = current_n1;
|
|
self.last_n2_set = current_n2;
|
|
|
|
Ok(sent_count)
|
|
}
|
|
|
|
/// Process a node list update from a peer: their N1 changes → our N2, their N2 changes → our N3.
|
|
pub async fn process_routing_diff(
|
|
&self,
|
|
reporter: &NodeId,
|
|
diff: NodeListUpdatePayload,
|
|
) -> anyhow::Result<usize> {
|
|
let storage = self.storage.lock().await;
|
|
let mut count = 0;
|
|
|
|
// Their N1 added → add to our N2 (filter self + already-connected)
|
|
if !diff.n1_added.is_empty() {
|
|
let filtered: Vec<NodeId> = diff.n1_added.iter()
|
|
.filter(|nid| **nid != self.our_node_id && !self.connections.contains_key(*nid))
|
|
.copied()
|
|
.collect();
|
|
if !filtered.is_empty() {
|
|
storage.add_peer_n1(reporter, &filtered)?;
|
|
}
|
|
count += filtered.len();
|
|
}
|
|
|
|
// Their N1 removed → remove from our N2
|
|
if !diff.n1_removed.is_empty() {
|
|
storage.remove_peer_n1(reporter, &diff.n1_removed)?;
|
|
count += diff.n1_removed.len();
|
|
}
|
|
|
|
// Their N2 added → add to our N3 (filter self)
|
|
if !diff.n2_added.is_empty() {
|
|
let filtered: Vec<NodeId> = diff.n2_added.iter()
|
|
.filter(|nid| **nid != self.our_node_id)
|
|
.copied()
|
|
.collect();
|
|
if !filtered.is_empty() {
|
|
storage.add_peer_n2(reporter, &filtered)?;
|
|
}
|
|
count += filtered.len();
|
|
}
|
|
|
|
// Their N2 removed → remove from our N3
|
|
if !diff.n2_removed.is_empty() {
|
|
storage.remove_peer_n2(reporter, &diff.n2_removed)?;
|
|
count += diff.n2_removed.len();
|
|
}
|
|
|
|
// Update last_diff_seq
|
|
let _ = storage.update_mesh_peer_seq(reporter, diff.seq);
|
|
|
|
Ok(count)
|
|
}
|
|
|
|
/// Broadcast a visibility update to all connected peers.
|
|
pub async fn broadcast_visibility_update(
|
|
&self,
|
|
update: &crate::types::VisibilityUpdate,
|
|
) -> usize {
|
|
let payload = VisibilityUpdatePayload {
|
|
updates: vec![update.clone()],
|
|
};
|
|
|
|
let mut sent = 0;
|
|
for (peer_id, pc) in &self.connections {
|
|
let result = async {
|
|
let mut send = pc.connection.open_uni().await?;
|
|
write_typed_message(&mut send, MessageType::VisibilityUpdate, &payload).await?;
|
|
send.finish()?;
|
|
anyhow::Ok(())
|
|
}
|
|
.await;
|
|
|
|
match result {
|
|
Ok(()) => sent += 1,
|
|
Err(e) => {
|
|
debug!(peer = hex::encode(peer_id), error = %e, "Failed to push visibility update");
|
|
}
|
|
}
|
|
}
|
|
|
|
sent
|
|
}
|
|
|
|
/// Handle an incoming post notification: if we follow the author, pull the post.
|
|
/// `conn` is a fallback connection for ephemeral callers (not persistently connected).
|
|
pub async fn handle_post_notification(
|
|
&self,
|
|
from: &NodeId,
|
|
notification: PostNotificationPayload,
|
|
conn: Option<&iroh::endpoint::Connection>,
|
|
) -> anyhow::Result<bool> {
|
|
let dominated = {
|
|
let storage = self.storage.lock().await;
|
|
// Already have this post?
|
|
if storage.get_post(¬ification.post_id)?.is_some() {
|
|
return Ok(false);
|
|
}
|
|
// Do we follow the author?
|
|
let follows = storage.list_follows()?;
|
|
follows.contains(¬ification.author)
|
|
};
|
|
|
|
if !dominated {
|
|
return Ok(false);
|
|
}
|
|
|
|
// We follow the author and don't have the post — pull it from the notifier
|
|
let pull_conn = match self.connections.get(from) {
|
|
Some(pc) => pc.connection.clone(),
|
|
None => match conn {
|
|
Some(c) => c.clone(),
|
|
None => return Ok(false),
|
|
},
|
|
};
|
|
|
|
let (our_follows, our_post_ids) = {
|
|
let storage = self.storage.lock().await;
|
|
(storage.list_follows()?, storage.list_post_ids()?)
|
|
};
|
|
|
|
let (mut send, mut recv) = pull_conn.open_bi().await?;
|
|
let request = PullSyncRequestPayload {
|
|
follows: our_follows,
|
|
have_post_ids: our_post_ids,
|
|
};
|
|
write_typed_message(&mut send, MessageType::PullSyncRequest, &request).await?;
|
|
send.finish()?;
|
|
|
|
let _resp_type = read_message_type(&mut recv).await?;
|
|
let response: PullSyncResponsePayload =
|
|
read_payload(&mut recv, MAX_PAYLOAD).await?;
|
|
|
|
let mut stored = false;
|
|
let mut new_post_ids: Vec<PostId> = Vec::new();
|
|
let storage = self.storage.lock().await;
|
|
for sp in response.posts {
|
|
if verify_post_id(&sp.id, &sp.post) && !storage.is_deleted(&sp.id)? {
|
|
let _ = storage.store_post_with_visibility(&sp.id, &sp.post, &sp.visibility);
|
|
let _ = storage.set_post_upstream(&sp.id, from);
|
|
new_post_ids.push(sp.id);
|
|
if sp.id == notification.post_id {
|
|
stored = true;
|
|
}
|
|
}
|
|
}
|
|
for vu in response.visibility_updates {
|
|
if let Some(post) = storage.get_post(&vu.post_id)? {
|
|
if post.author == vu.author {
|
|
let _ = storage.update_post_visibility(&vu.post_id, &vu.visibility);
|
|
}
|
|
}
|
|
}
|
|
drop(storage);
|
|
|
|
// Register as downstream for new posts
|
|
if !new_post_ids.is_empty() {
|
|
let reg_conn = pull_conn.clone();
|
|
tokio::spawn(async move {
|
|
for post_id in new_post_ids {
|
|
let payload = PostDownstreamRegisterPayload { post_id };
|
|
if let Ok(mut send) = reg_conn.open_uni().await {
|
|
let _ = write_typed_message(&mut send, MessageType::PostDownstreamRegister, &payload).await;
|
|
let _ = send.finish();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
Ok(stored)
|
|
}
|
|
|
|
/// Pull posts from a connected peer.
|
|
pub async fn pull_from_peer(&self, peer_id: &NodeId) -> anyhow::Result<PullSyncStats> {
|
|
let pc = self
|
|
.connections
|
|
.get(peer_id)
|
|
.ok_or_else(|| anyhow::anyhow!("not connected to {}", hex::encode(peer_id)))?;
|
|
|
|
let (our_follows, our_post_ids) = {
|
|
let storage = self.storage.lock().await;
|
|
(storage.list_follows()?, storage.list_post_ids()?)
|
|
};
|
|
|
|
let request = PullSyncRequestPayload {
|
|
follows: our_follows,
|
|
have_post_ids: our_post_ids,
|
|
};
|
|
|
|
let (mut send, mut recv) = pc.connection.open_bi().await?;
|
|
write_typed_message(&mut send, MessageType::PullSyncRequest, &request).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, MAX_PAYLOAD).await?;
|
|
|
|
let mut posts_received = 0;
|
|
let mut vis_updates = 0;
|
|
let mut new_post_ids: Vec<PostId> = Vec::new();
|
|
|
|
{
|
|
let storage = self.storage.lock().await;
|
|
|
|
for sp in response.posts {
|
|
if storage.is_deleted(&sp.id)? {
|
|
continue;
|
|
}
|
|
if verify_post_id(&sp.id, &sp.post) {
|
|
if storage.store_post_with_visibility(&sp.id, &sp.post, &sp.visibility)? {
|
|
// Record who we got this post from (upstream for engagement propagation)
|
|
let _ = storage.set_post_upstream(&sp.id, peer_id);
|
|
new_post_ids.push(sp.id);
|
|
posts_received += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
for vu in response.visibility_updates {
|
|
if vu.author != *peer_id {
|
|
// Only accept visibility updates authored by the responding peer
|
|
// (or their forwarded data — but for now, only from the peer)
|
|
}
|
|
if let Some(post) = storage.get_post(&vu.post_id)? {
|
|
if post.author == vu.author {
|
|
if storage.update_post_visibility(&vu.post_id, &vu.visibility)? {
|
|
vis_updates += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Register as downstream with the sender for new posts
|
|
// so they push engagement diffs (reactions, comments) to us
|
|
if !new_post_ids.is_empty() {
|
|
let conn = pc.connection.clone();
|
|
tokio::spawn(async move {
|
|
for post_id in new_post_ids {
|
|
let payload = PostDownstreamRegisterPayload { post_id };
|
|
if let Ok(mut send) = conn.open_uni().await {
|
|
let _ = write_typed_message(&mut send, MessageType::PostDownstreamRegister, &payload).await;
|
|
let _ = send.finish();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
Ok(PullSyncStats {
|
|
posts_received,
|
|
visibility_updates: vis_updates,
|
|
})
|
|
}
|
|
|
|
/// Fetch engagement headers (reactions, comments, policies) for our posts from a peer.
|
|
/// Requests BlobHeader for each post we hold, applies newer data.
|
|
pub async fn fetch_engagement_from_peer(&self, peer_id: &NodeId) -> anyhow::Result<usize> {
|
|
let pc = self
|
|
.connections
|
|
.get(peer_id)
|
|
.ok_or_else(|| anyhow::anyhow!("not connected to {}", hex::encode(peer_id)))?;
|
|
|
|
// Get post IDs and their current header timestamps
|
|
let post_headers: Vec<([u8; 32], u64)> = {
|
|
let storage = self.storage.lock().await;
|
|
let post_ids = storage.list_post_ids()?;
|
|
post_ids
|
|
.into_iter()
|
|
.map(|pid| {
|
|
let ts = storage
|
|
.get_blob_header(&pid)
|
|
.ok()
|
|
.flatten()
|
|
.map(|(_, ts)| ts)
|
|
.unwrap_or(0);
|
|
(pid, ts)
|
|
})
|
|
.collect()
|
|
};
|
|
|
|
let mut updated = 0;
|
|
// Request headers in batches to avoid opening too many streams
|
|
for chunk in post_headers.chunks(20) {
|
|
for (post_id, current_ts) in chunk {
|
|
let result: anyhow::Result<()> = async {
|
|
let (mut send, mut recv) = pc.connection.open_bi().await?;
|
|
let request = BlobHeaderRequestPayload {
|
|
post_id: *post_id,
|
|
current_updated_at: *current_ts,
|
|
};
|
|
write_typed_message(&mut send, MessageType::BlobHeaderRequest, &request).await?;
|
|
send.finish()?;
|
|
|
|
let msg_type = read_message_type(&mut recv).await?;
|
|
if msg_type != MessageType::BlobHeaderResponse {
|
|
anyhow::bail!("expected BlobHeaderResponse");
|
|
}
|
|
let response: BlobHeaderResponsePayload =
|
|
read_payload(&mut recv, MAX_PAYLOAD).await?;
|
|
|
|
if response.updated {
|
|
if let Some(json) = &response.header_json {
|
|
if let Ok(header) = serde_json::from_str::<crate::types::BlobHeader>(json) {
|
|
let storage = self.storage.lock().await;
|
|
// Store the full header JSON
|
|
let _ = storage.store_blob_header(
|
|
&header.post_id,
|
|
&header.author,
|
|
json,
|
|
header.updated_at,
|
|
);
|
|
// Apply individual reactions and comments
|
|
for reaction in &header.reactions {
|
|
let _ = storage.store_reaction(reaction);
|
|
}
|
|
for comment in &header.comments {
|
|
let _ = storage.store_comment(comment);
|
|
}
|
|
let _ = storage.set_comment_policy(&header.post_id, &header.policy);
|
|
updated += 1;
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
.await;
|
|
if let Err(e) = result {
|
|
trace!(post_id = hex::encode(post_id), error = %e, "Failed to fetch engagement header");
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(updated)
|
|
}
|
|
|
|
/// Handle an incoming pull request from a peer.
|
|
pub async fn handle_pull_request(
|
|
&self,
|
|
remote_node_id: NodeId,
|
|
mut recv: iroh::endpoint::RecvStream,
|
|
mut send: iroh::endpoint::SendStream,
|
|
) -> anyhow::Result<()> {
|
|
let request: PullSyncRequestPayload = read_payload(&mut recv, MAX_PAYLOAD).await?;
|
|
|
|
let their_follows: HashSet<NodeId> = request.follows.into_iter().collect();
|
|
let their_post_ids: HashSet<[u8; 32]> = request.have_post_ids.into_iter().collect();
|
|
|
|
let (posts, vis_updates) = {
|
|
let storage = self.storage.lock().await;
|
|
let all_posts = storage.list_posts_with_visibility()?;
|
|
let group_members = storage.get_all_group_members().unwrap_or_default();
|
|
|
|
let mut posts_to_send = Vec::new();
|
|
let mut vis_updates_to_send = Vec::new();
|
|
|
|
for (id, post, visibility) in all_posts {
|
|
let should_send =
|
|
crate::network::should_send_post(&post, &visibility, &remote_node_id, &their_follows, &group_members);
|
|
|
|
if should_send && !their_post_ids.contains(&id) {
|
|
if !storage.is_deleted(&id)? {
|
|
posts_to_send.push(SyncPost {
|
|
id,
|
|
post,
|
|
visibility,
|
|
});
|
|
}
|
|
} else if should_send && their_post_ids.contains(&id) {
|
|
// They already have the post — send visibility update if we authored it
|
|
if post.author == self.our_node_id {
|
|
vis_updates_to_send.push(crate::types::VisibilityUpdate {
|
|
post_id: id,
|
|
author: self.our_node_id,
|
|
visibility,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
(posts_to_send, vis_updates_to_send)
|
|
};
|
|
|
|
let response = PullSyncResponsePayload {
|
|
posts,
|
|
visibility_updates: vis_updates,
|
|
};
|
|
|
|
write_typed_message(&mut send, MessageType::PullSyncResponse, &response).await?;
|
|
send.finish()?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Handle an address resolution request: check connections, social routes, then peer records.
|
|
/// If target is disconnected, register the requester as a watcher.
|
|
pub async fn handle_address_request(
|
|
&self,
|
|
mut recv: iroh::endpoint::RecvStream,
|
|
mut send: iroh::endpoint::SendStream,
|
|
requester: NodeId,
|
|
) -> anyhow::Result<()> {
|
|
let req: crate::protocol::AddressRequestPayload =
|
|
read_payload(&mut recv, MAX_PAYLOAD).await?;
|
|
|
|
// Check if target is directly connected to us
|
|
if let Some(_pc) = self.connections.get(&req.target) {
|
|
let storage = self.storage.lock().await;
|
|
let addr = storage.get_peer_record(&req.target)?
|
|
.and_then(|r| r.addresses.first().map(|a| a.to_string()));
|
|
let response = crate::protocol::AddressResponsePayload {
|
|
target: req.target,
|
|
address: addr,
|
|
disconnected_at: None,
|
|
peer_addresses: vec![],
|
|
};
|
|
write_typed_message(&mut send, MessageType::AddressResponse, &response).await?;
|
|
send.finish()?;
|
|
return Ok(());
|
|
}
|
|
|
|
let storage = self.storage.lock().await;
|
|
|
|
// Check social routes (richer info)
|
|
if let Some(route) = storage.get_social_route(&req.target)? {
|
|
match route.status {
|
|
SocialStatus::Online => {
|
|
let addr = route.addresses.first().map(|a| a.to_string());
|
|
let response = crate::protocol::AddressResponsePayload {
|
|
target: req.target,
|
|
address: addr,
|
|
disconnected_at: None,
|
|
peer_addresses: route.peer_addresses.clone(),
|
|
};
|
|
write_typed_message(&mut send, MessageType::AddressResponse, &response).await?;
|
|
send.finish()?;
|
|
return Ok(());
|
|
}
|
|
SocialStatus::Disconnected => {
|
|
let _ = storage.add_reconnect_watcher(&req.target, &requester);
|
|
let response = crate::protocol::AddressResponsePayload {
|
|
target: req.target,
|
|
address: None,
|
|
disconnected_at: Some(route.last_seen_ms),
|
|
peer_addresses: route.peer_addresses.clone(),
|
|
};
|
|
write_typed_message(&mut send, MessageType::AddressResponse, &response).await?;
|
|
send.finish()?;
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fall back to peer record
|
|
let address = storage.get_peer_record(&req.target)?
|
|
.and_then(|r| r.addresses.first().map(|a| a.to_string()));
|
|
|
|
let response = crate::protocol::AddressResponsePayload {
|
|
target: req.target,
|
|
address,
|
|
disconnected_at: None,
|
|
peer_addresses: vec![],
|
|
};
|
|
write_typed_message(&mut send, MessageType::AddressResponse, &response).await?;
|
|
send.finish()?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Resolve a peer's address using connections, social routes, and N2/N3 referral chain.
|
|
pub async fn resolve_address(&self, target: &NodeId) -> anyhow::Result<Option<String>> {
|
|
// Check if target is directly connected
|
|
if self.connections.contains_key(target) {
|
|
let storage = self.storage.lock().await;
|
|
return Ok(storage.get_peer_record(target)?
|
|
.and_then(|r| r.addresses.first().map(|a| a.to_string())));
|
|
}
|
|
|
|
// Check social routes
|
|
{
|
|
let storage = self.storage.lock().await;
|
|
if let Some(route) = storage.get_social_route(target)? {
|
|
if route.status == SocialStatus::Online {
|
|
if let Some(addr) = route.addresses.first() {
|
|
return Ok(Some(addr.to_string()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// N2 lookup: ask tagged reporter for address
|
|
let n2_reporters = {
|
|
let storage = self.storage.lock().await;
|
|
storage.find_in_n2(target)?
|
|
};
|
|
for reporter in &n2_reporters {
|
|
if let Some(pc) = self.connections.get(reporter) {
|
|
let result = async {
|
|
let (mut send, mut recv) = pc.connection.open_bi().await?;
|
|
let req = crate::protocol::AddressRequestPayload { target: *target };
|
|
write_typed_message(&mut send, MessageType::AddressRequest, &req).await?;
|
|
send.finish()?;
|
|
let _resp_type = read_message_type(&mut recv).await?;
|
|
let resp: crate::protocol::AddressResponsePayload =
|
|
read_payload(&mut recv, MAX_PAYLOAD).await?;
|
|
anyhow::Ok(resp.address)
|
|
}.await;
|
|
if let Ok(Some(addr)) = result {
|
|
return Ok(Some(addr));
|
|
}
|
|
}
|
|
}
|
|
|
|
// N3 lookup: ask tagged reporter (chains one more hop)
|
|
let n3_reporters = {
|
|
let storage = self.storage.lock().await;
|
|
storage.find_in_n3(target)?
|
|
};
|
|
for reporter in &n3_reporters {
|
|
if let Some(pc) = self.connections.get(reporter) {
|
|
let result = async {
|
|
let (mut send, mut recv) = pc.connection.open_bi().await?;
|
|
let req = crate::protocol::AddressRequestPayload { target: *target };
|
|
write_typed_message(&mut send, MessageType::AddressRequest, &req).await?;
|
|
send.finish()?;
|
|
let _resp_type = read_message_type(&mut recv).await?;
|
|
let resp: crate::protocol::AddressResponsePayload =
|
|
read_payload(&mut recv, MAX_PAYLOAD).await?;
|
|
anyhow::Ok(resp.address)
|
|
}.await;
|
|
if let Ok(Some(addr)) = result {
|
|
return Ok(Some(addr));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
/// Resolve a peer's address from all local sources (no network requests).
|
|
/// Checks: peers table → social route cache.
|
|
pub async fn resolve_peer_addr_local(&self, peer_id: &NodeId) -> Option<iroh::EndpointAddr> {
|
|
let endpoint_id = iroh::EndpointId::from_bytes(peer_id).ok()?;
|
|
let storage = self.storage.lock().await;
|
|
|
|
// 1. Peers table
|
|
if let Ok(Some(rec)) = storage.get_peer_record(peer_id) {
|
|
if let Some(addr) = rec.addresses.first() {
|
|
return Some(iroh::EndpointAddr::from(endpoint_id).with_ip_addr(*addr));
|
|
}
|
|
}
|
|
// 2. Social route cache
|
|
if let Ok(Some(route)) = storage.get_social_route(peer_id) {
|
|
if let Some(addr) = route.addresses.first() {
|
|
return Some(iroh::EndpointAddr::from(endpoint_id).with_ip_addr(*addr));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Pick a random connected peer with a known address (for RefuseRedirect).
|
|
pub async fn pick_random_redirect_peer(&self, exclude: &NodeId) -> Option<PeerWithAddress> {
|
|
let candidates: Vec<NodeId> = self.connections.keys()
|
|
.filter(|nid| *nid != exclude && **nid != self.our_node_id)
|
|
.copied()
|
|
.collect();
|
|
if candidates.is_empty() {
|
|
return None;
|
|
}
|
|
let storage = self.storage.lock().await;
|
|
for nid in &candidates {
|
|
if let Ok(Some(rec)) = storage.get_peer_record(nid) {
|
|
if let Some(addr) = rec.addresses.first() {
|
|
return Some(PeerWithAddress {
|
|
n: hex::encode(nid),
|
|
a: vec![addr.to_string()],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
// ---- Worm lookup (wide-bloom + 11-needle) ----
|
|
|
|
/// Initiate a worm content search — find a post (and optionally its author node).
|
|
/// Uses the extended worm with post_id/blob_id fields.
|
|
pub async fn initiate_content_search(
|
|
&self,
|
|
target: &NodeId,
|
|
post_id: Option<PostId>,
|
|
blob_id: Option<[u8; 32]>,
|
|
) -> anyhow::Result<Option<WormResult>> {
|
|
// Gather needle_peers: target's recent_peers from stored profile (up to 10)
|
|
let needle_peers: Vec<NodeId> = {
|
|
let storage = self.storage.lock().await;
|
|
let mut rp = storage.get_recent_peers(target)?;
|
|
rp.truncate(10);
|
|
rp
|
|
};
|
|
|
|
let mut all_needles = vec![*target];
|
|
all_needles.extend_from_slice(&needle_peers);
|
|
|
|
let worm_id: WormId = rand::random();
|
|
let visited = vec![self.our_node_id];
|
|
|
|
let result = tokio::time::timeout(
|
|
std::time::Duration::from_millis(WORM_TOTAL_TIMEOUT_MS),
|
|
self.originator_worm_cascade(target, &needle_peers, &worm_id, &visited, &all_needles, post_id, blob_id),
|
|
)
|
|
.await;
|
|
|
|
match result {
|
|
Ok(Ok(Some(wr))) => Ok(Some(wr)),
|
|
Ok(Ok(None)) => Ok(None),
|
|
Ok(Err(e)) => {
|
|
debug!(error = %e, "Content search failed");
|
|
Ok(None)
|
|
}
|
|
Err(_) => {
|
|
debug!("Content search timed out");
|
|
Ok(None)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Initiate a worm lookup using the wide-bloom + 11-needle algorithm.
|
|
/// Called by the originator node. Returns WormResult if found.
|
|
pub async fn initiate_worm_lookup(&self, target: &NodeId) -> anyhow::Result<Option<WormResult>> {
|
|
// Check cooldown
|
|
{
|
|
let storage = self.storage.lock().await;
|
|
if storage.is_worm_cooldown(target, WORM_COOLDOWN_MS)? {
|
|
debug!(target = hex::encode(target), "Worm lookup on cooldown");
|
|
return Ok(None);
|
|
}
|
|
}
|
|
|
|
// Gather needle_peers: target's recent_peers from stored profile (up to 10)
|
|
let needle_peers: Vec<NodeId> = {
|
|
let storage = self.storage.lock().await;
|
|
let mut rp = storage.get_recent_peers(target)?;
|
|
rp.truncate(10);
|
|
rp
|
|
};
|
|
|
|
// All 11 IDs = [target] + needle_peers
|
|
let mut all_needles = vec![*target];
|
|
all_needles.extend_from_slice(&needle_peers);
|
|
|
|
// Generate worm_id
|
|
let worm_id: WormId = rand::random();
|
|
let visited = vec![self.our_node_id];
|
|
|
|
// Run with total timeout
|
|
let result = tokio::time::timeout(
|
|
std::time::Duration::from_millis(WORM_TOTAL_TIMEOUT_MS),
|
|
self.originator_worm_cascade(target, &needle_peers, &worm_id, &visited, &all_needles, None, None),
|
|
)
|
|
.await;
|
|
|
|
match result {
|
|
Ok(Ok(Some(wr))) => Ok(Some(wr)),
|
|
Ok(Ok(None)) => {
|
|
let storage = self.storage.lock().await;
|
|
let _ = storage.record_worm_miss(target);
|
|
Ok(None)
|
|
}
|
|
Ok(Err(e)) => {
|
|
debug!(target = hex::encode(target), error = %e, "Worm lookup failed");
|
|
let storage = self.storage.lock().await;
|
|
let _ = storage.record_worm_miss(target);
|
|
Err(e)
|
|
}
|
|
Err(_) => {
|
|
debug!(target = hex::encode(target), "Worm lookup timed out");
|
|
let storage = self.storage.lock().await;
|
|
let _ = storage.record_worm_miss(target);
|
|
Ok(None)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Originator-driven worm cascade: local check → fan-out → wide-bloom.
|
|
async fn originator_worm_cascade(
|
|
&self,
|
|
target: &NodeId,
|
|
needle_peers: &[NodeId],
|
|
worm_id: &WormId,
|
|
visited: &[NodeId],
|
|
all_needles: &[NodeId],
|
|
post_id: Option<PostId>,
|
|
blob_id: Option<[u8; 32]>,
|
|
) -> anyhow::Result<Option<WormResult>> {
|
|
// Step 0: Local check — find any of the 11 IDs in our connections, N2, or N3
|
|
{
|
|
// Check direct connections first
|
|
for needle in all_needles {
|
|
if self.connections.contains_key(needle) {
|
|
let storage = self.storage.lock().await;
|
|
let addr = storage.get_peer_record(needle)?
|
|
.and_then(|r| r.addresses.first().map(|a| a.to_string()));
|
|
drop(storage);
|
|
return Ok(Some(WormResult {
|
|
node_id: *needle,
|
|
addresses: addr.into_iter().collect(),
|
|
reporter: self.our_node_id,
|
|
freshness_ms: 0,
|
|
post_holder: None,
|
|
blob_holder: None,
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Check N2/N3
|
|
let storage = self.storage.lock().await;
|
|
let found_entries = storage.find_any_in_n2_n3(all_needles)?;
|
|
if let Some((found_id, _reporter, _level)) = found_entries.first() {
|
|
drop(storage);
|
|
let address = self.resolve_address(found_id).await.unwrap_or(None);
|
|
if let Some(addr) = address {
|
|
return Ok(Some(WormResult {
|
|
node_id: *found_id,
|
|
addresses: vec![addr],
|
|
reporter: self.our_node_id,
|
|
freshness_ms: 0,
|
|
post_holder: None,
|
|
blob_holder: None,
|
|
}));
|
|
}
|
|
return Ok(Some(WormResult {
|
|
node_id: *found_id,
|
|
addresses: vec![],
|
|
reporter: self.our_node_id,
|
|
freshness_ms: 0,
|
|
post_holder: None,
|
|
blob_holder: None,
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Step 2: Fan-out — send WormQuery{ttl=0} to all connected peers, collect ALL responses
|
|
let peer_conns: Vec<(NodeId, iroh::endpoint::Connection)> = {
|
|
let visited_set: HashSet<NodeId> = visited.iter().copied().collect();
|
|
self.connections
|
|
.iter()
|
|
.filter(|(nid, _)| !visited_set.contains(*nid))
|
|
.map(|(nid, pc)| (*nid, pc.connection.clone()))
|
|
.collect()
|
|
};
|
|
|
|
if !peer_conns.is_empty() {
|
|
let fan_out_payload = WormQueryPayload {
|
|
worm_id: *worm_id,
|
|
target: *target,
|
|
needle_peers: needle_peers.to_vec(),
|
|
ttl: 0,
|
|
visited: visited.to_vec(),
|
|
post_id: post_id.clone(),
|
|
blob_id,
|
|
};
|
|
|
|
let (hit, wide_referrals) = tokio::time::timeout(
|
|
std::time::Duration::from_millis(WORM_FAN_OUT_TIMEOUT_MS),
|
|
Self::fan_out_worm_query_all(&peer_conns, &fan_out_payload),
|
|
)
|
|
.await
|
|
.unwrap_or((None, vec![]));
|
|
|
|
if let Some(wr) = hit {
|
|
return Ok(Some(wr));
|
|
}
|
|
|
|
// Step 3: Wide-bloom — connect to referred wide peers, send WormQuery{ttl=1}
|
|
if !wide_referrals.is_empty() {
|
|
let bloom_payload = WormQueryPayload {
|
|
worm_id: *worm_id,
|
|
target: *target,
|
|
needle_peers: needle_peers.to_vec(),
|
|
ttl: 1,
|
|
visited: visited.to_vec(),
|
|
post_id: post_id.clone(),
|
|
blob_id,
|
|
};
|
|
|
|
let bloom_result = tokio::time::timeout(
|
|
std::time::Duration::from_millis(WORM_BLOOM_TIMEOUT_MS),
|
|
self.bloom_to_wide_peers(&wide_referrals, &bloom_payload),
|
|
)
|
|
.await;
|
|
|
|
if let Ok(Some(wr)) = bloom_result {
|
|
return Ok(Some(wr));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
/// Send a PostFetch request to a specific peer and return the post if found.
|
|
async fn send_post_fetch(
|
|
&self,
|
|
holder: &NodeId,
|
|
post_id: &PostId,
|
|
) -> anyhow::Result<Option<crate::protocol::SyncPost>> {
|
|
use crate::protocol::{PostFetchRequestPayload, PostFetchResponsePayload};
|
|
|
|
// Get connection to the holder (mesh or session)
|
|
let conn = self.connections.get(holder)
|
|
.map(|pc| pc.connection.clone())
|
|
.or_else(|| self.sessions.get(holder).map(|sc| sc.connection.clone()));
|
|
|
|
let conn = match conn {
|
|
Some(c) => c,
|
|
None => {
|
|
// Try to connect via endpoint (resolve address first)
|
|
let addr = self.resolve_address(holder).await.unwrap_or(None);
|
|
if let Some(addr_str) = addr {
|
|
let endpoint_id = iroh::EndpointId::from_bytes(holder)
|
|
.map_err(|_| anyhow::anyhow!("Invalid endpoint ID"))?;
|
|
let mut ep_addr = iroh::EndpointAddr::from(endpoint_id);
|
|
if let Ok(sock) = addr_str.parse::<std::net::SocketAddr>() {
|
|
ep_addr = ep_addr.with_ip_addr(sock);
|
|
}
|
|
self.endpoint.connect(ep_addr, ALPN_V2).await?
|
|
} else {
|
|
return Err(anyhow::anyhow!("No connection or address for post holder"));
|
|
}
|
|
}
|
|
};
|
|
|
|
let (mut send, mut recv) = conn.open_bi().await?;
|
|
let req = PostFetchRequestPayload { post_id: *post_id };
|
|
write_typed_message(&mut send, MessageType::PostFetchRequest, &req).await?;
|
|
send.finish()?;
|
|
|
|
let msg_type = tokio::time::timeout(
|
|
std::time::Duration::from_secs(10),
|
|
read_message_type(&mut recv),
|
|
).await??;
|
|
|
|
if msg_type != MessageType::PostFetchResponse {
|
|
return Ok(None);
|
|
}
|
|
|
|
let resp: PostFetchResponsePayload = read_payload(&mut recv, MAX_PAYLOAD).await?;
|
|
Ok(resp.post)
|
|
}
|
|
|
|
/// Send a TcpPunchRequest to a peer, asking them to punch a TCP hole toward a browser IP.
|
|
/// Returns the peer's HTTP address if the punch succeeded.
|
|
async fn send_tcp_punch(
|
|
&self,
|
|
holder: &NodeId,
|
|
browser_ip: String,
|
|
post_id: &PostId,
|
|
) -> anyhow::Result<Option<String>> {
|
|
use crate::protocol::{TcpPunchRequestPayload, TcpPunchResultPayload};
|
|
|
|
// Get connection to the holder (mesh or session)
|
|
let conn = self.connections.get(holder)
|
|
.map(|pc| pc.connection.clone())
|
|
.or_else(|| self.sessions.get(holder).map(|sc| sc.connection.clone()));
|
|
|
|
let conn = match conn {
|
|
Some(c) => c,
|
|
None => {
|
|
// Try to connect via endpoint
|
|
let addr = self.resolve_address(holder).await.unwrap_or(None);
|
|
if let Some(addr_str) = addr {
|
|
let endpoint_id = iroh::EndpointId::from_bytes(holder)
|
|
.map_err(|_| anyhow::anyhow!("Invalid endpoint ID"))?;
|
|
let mut ep_addr = iroh::EndpointAddr::from(endpoint_id);
|
|
if let Ok(sock) = addr_str.parse::<std::net::SocketAddr>() {
|
|
ep_addr = ep_addr.with_ip_addr(sock);
|
|
}
|
|
self.endpoint.connect(ep_addr, ALPN_V2).await?
|
|
} else {
|
|
return Err(anyhow::anyhow!("No connection or address for punch target"));
|
|
}
|
|
}
|
|
};
|
|
|
|
let (mut send, mut recv) = conn.open_bi().await?;
|
|
let req = TcpPunchRequestPayload {
|
|
browser_ip,
|
|
post_id: *post_id,
|
|
};
|
|
write_typed_message(&mut send, MessageType::TcpPunchRequest, &req).await?;
|
|
send.finish()?;
|
|
|
|
let msg_type = tokio::time::timeout(
|
|
std::time::Duration::from_secs(5),
|
|
read_message_type(&mut recv),
|
|
).await??;
|
|
|
|
if msg_type != MessageType::TcpPunchResult {
|
|
return Ok(None);
|
|
}
|
|
|
|
let resp: TcpPunchResultPayload = read_payload(&mut recv, 4096).await?;
|
|
if resp.success {
|
|
Ok(resp.http_addr)
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
/// Fan out a worm query to multiple peers, collecting ALL responses.
|
|
/// Returns (first_hit, all_wide_referrals).
|
|
async fn fan_out_worm_query_all(
|
|
peer_conns: &[(NodeId, iroh::endpoint::Connection)],
|
|
payload: &WormQueryPayload,
|
|
) -> (Option<WormResult>, Vec<(NodeId, String)>) {
|
|
use tokio::task::JoinSet;
|
|
|
|
let mut set = JoinSet::new();
|
|
for (_nid, conn) in peer_conns {
|
|
let conn = conn.clone();
|
|
let payload = WormQueryPayload {
|
|
worm_id: payload.worm_id,
|
|
target: payload.target,
|
|
needle_peers: payload.needle_peers.clone(),
|
|
ttl: payload.ttl,
|
|
visited: payload.visited.clone(),
|
|
post_id: payload.post_id.clone(),
|
|
blob_id: payload.blob_id,
|
|
};
|
|
set.spawn(async move { Self::send_worm_query_raw(&conn, &payload).await });
|
|
}
|
|
|
|
let mut first_hit: Option<WormResult> = None;
|
|
let mut wide_referrals: Vec<(NodeId, String)> = Vec::new();
|
|
|
|
while let Some(result) = set.join_next().await {
|
|
if let Ok(Ok(resp)) = result {
|
|
// Collect wide referral regardless of hit
|
|
if let Some((ref_id, ref_addr)) = resp.wide_referral {
|
|
wide_referrals.push((ref_id, ref_addr));
|
|
}
|
|
|
|
// Treat content-found as a hit too (post/blob holder without node match)
|
|
let is_hit = resp.found || resp.post_holder.is_some() || resp.blob_holder.is_some();
|
|
if is_hit && first_hit.is_none() {
|
|
let found_node = resp.found_id.unwrap_or(payload.target);
|
|
first_hit = Some(WormResult {
|
|
node_id: found_node,
|
|
addresses: resp.addresses.clone(),
|
|
reporter: resp.reporter.unwrap_or([0u8; 32]),
|
|
freshness_ms: 0,
|
|
post_holder: resp.post_holder,
|
|
blob_holder: resp.blob_holder,
|
|
});
|
|
set.abort_all();
|
|
}
|
|
}
|
|
}
|
|
|
|
(first_hit, wide_referrals)
|
|
}
|
|
|
|
/// Connect to referred wide peers and send WormQuery{ttl=1}.
|
|
/// First hit wins (abort rest).
|
|
async fn bloom_to_wide_peers(
|
|
&self,
|
|
referrals: &[(NodeId, String)],
|
|
payload: &WormQueryPayload,
|
|
) -> Option<WormResult> {
|
|
use tokio::task::JoinSet;
|
|
|
|
// Deduplicate referrals by node_id
|
|
let mut seen = HashSet::new();
|
|
let unique_referrals: Vec<&(NodeId, String)> = referrals
|
|
.iter()
|
|
.filter(|(nid, _)| {
|
|
// Skip if it's us, already connected, or duplicate
|
|
*nid != self.our_node_id
|
|
&& !self.connections.contains_key(nid)
|
|
&& seen.insert(*nid)
|
|
})
|
|
.collect();
|
|
|
|
if unique_referrals.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
debug!(count = unique_referrals.len(), "Bloom round: connecting to wide peers");
|
|
|
|
let mut set = JoinSet::new();
|
|
let endpoint = self.endpoint.clone();
|
|
|
|
for (ref_id, ref_addr) in unique_referrals {
|
|
let endpoint = endpoint.clone();
|
|
let ref_id = *ref_id;
|
|
let ref_addr = ref_addr.clone();
|
|
let payload = WormQueryPayload {
|
|
worm_id: payload.worm_id,
|
|
target: payload.target,
|
|
needle_peers: payload.needle_peers.clone(),
|
|
ttl: payload.ttl,
|
|
visited: payload.visited.clone(),
|
|
post_id: payload.post_id.clone(),
|
|
blob_id: payload.blob_id,
|
|
};
|
|
|
|
set.spawn(async move {
|
|
// Parse address and connect
|
|
let endpoint_id = iroh::EndpointId::from_bytes(&ref_id).ok()?;
|
|
let mut addr = iroh::EndpointAddr::from(endpoint_id);
|
|
if let Ok(sock) = ref_addr.parse::<std::net::SocketAddr>() {
|
|
addr = addr.with_ip_addr(sock);
|
|
}
|
|
|
|
let conn = endpoint.connect(addr, ALPN_V2).await.ok()?;
|
|
let resp = Self::send_worm_query_raw(&conn, &payload).await.ok()?;
|
|
|
|
let is_hit = resp.found || resp.post_holder.is_some() || resp.blob_holder.is_some();
|
|
if is_hit {
|
|
let found_node = resp.found_id.unwrap_or([0u8; 32]);
|
|
Some(WormResult {
|
|
node_id: found_node,
|
|
addresses: resp.addresses,
|
|
reporter: resp.reporter.unwrap_or([0u8; 32]),
|
|
freshness_ms: 0,
|
|
post_holder: resp.post_holder,
|
|
blob_holder: resp.blob_holder,
|
|
})
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
}
|
|
|
|
while let Some(result) = set.join_next().await {
|
|
if let Ok(Some(wr)) = result {
|
|
set.abort_all();
|
|
return Some(wr);
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Send a WormQuery on a bi-stream and read the raw WormResponsePayload.
|
|
async fn send_worm_query_raw(
|
|
conn: &iroh::endpoint::Connection,
|
|
payload: &WormQueryPayload,
|
|
) -> anyhow::Result<WormResponsePayload> {
|
|
let (mut send, mut recv) = conn.open_bi().await?;
|
|
write_typed_message(&mut send, MessageType::WormQuery, payload).await?;
|
|
send.finish()?;
|
|
|
|
let msg_type = read_message_type(&mut recv).await?;
|
|
if msg_type != MessageType::WormResponse {
|
|
anyhow::bail!("expected WormResponse, got {:?}", msg_type);
|
|
}
|
|
let resp: WormResponsePayload = read_payload(&mut recv, MAX_PAYLOAD).await?;
|
|
Ok(resp)
|
|
}
|
|
|
|
/// Handle an incoming WormQuery from a peer.
|
|
/// Checks local map for ALL needles. If ttl > 0, also fans out to own peers.
|
|
/// Always includes a wide_referral in the response if available.
|
|
pub async fn handle_worm_query(
|
|
&mut self,
|
|
payload: WormQueryPayload,
|
|
mut send: iroh::endpoint::SendStream,
|
|
from_peer: NodeId,
|
|
) -> anyhow::Result<()> {
|
|
let now = now_ms();
|
|
|
|
// Dedup: check if we've already seen this worm_id
|
|
if let Some(&seen_at) = self.seen_worms.get(&payload.worm_id) {
|
|
if now - seen_at < WORM_DEDUP_EXPIRY_MS {
|
|
let resp = WormResponsePayload {
|
|
worm_id: payload.worm_id,
|
|
found: false,
|
|
found_id: None,
|
|
addresses: vec![],
|
|
reporter: None,
|
|
hop: None,
|
|
wide_referral: None,
|
|
post_holder: None,
|
|
blob_holder: None,
|
|
};
|
|
write_typed_message(&mut send, MessageType::WormResponse, &resp).await?;
|
|
send.finish()?;
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
// Register this worm_id
|
|
self.seen_worms.insert(payload.worm_id, now);
|
|
|
|
// Prune old entries periodically
|
|
if self.seen_worms.len() > 1000 {
|
|
self.seen_worms
|
|
.retain(|_, ts| now - *ts < WORM_DEDUP_EXPIRY_MS);
|
|
}
|
|
|
|
// Check for post/blob content locally (CDN tree, replicas, blob store)
|
|
let mut post_holder: Option<NodeId> = None;
|
|
let mut blob_holder: Option<NodeId> = None;
|
|
|
|
if let Some(ref post_id) = payload.post_id {
|
|
let found = {
|
|
let store = self.storage.lock().await;
|
|
// Direct: do we have this post?
|
|
if store.get_post_with_visibility(post_id).ok().flatten().is_some() {
|
|
Some(self.our_node_id)
|
|
} else {
|
|
// CDN tree: do any of our downstream hosts have it?
|
|
let downstream = store.get_post_downstream(post_id).unwrap_or_default();
|
|
if !downstream.is_empty() {
|
|
Some(downstream[0])
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
};
|
|
post_holder = found;
|
|
}
|
|
|
|
if let Some(ref blob_id) = payload.blob_id {
|
|
// Check if we have the blob locally
|
|
if self.blob_store.get(blob_id).ok().flatten().is_some() {
|
|
blob_holder = Some(self.our_node_id);
|
|
} else {
|
|
// Check CDN: do we know who has it via blob post ownership?
|
|
let store = self.storage.lock().await;
|
|
if let Ok(Some(pid)) = store.get_blob_post_id(blob_id) {
|
|
let downstream = store.get_post_downstream(&pid).unwrap_or_default();
|
|
if !downstream.is_empty() {
|
|
blob_holder = Some(downstream[0]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build needle list: target + needle_peers
|
|
let mut all_needles = vec![payload.target];
|
|
all_needles.extend_from_slice(&payload.needle_peers);
|
|
|
|
// Step 1: Check connections + N2/N3 for ALL needle IDs
|
|
let local_result = {
|
|
// Check direct connections first
|
|
let mut found = None;
|
|
for needle in &all_needles {
|
|
if self.connections.contains_key(needle) {
|
|
let storage = self.storage.lock().await;
|
|
let addr = storage.get_peer_record(needle)?
|
|
.and_then(|r| r.addresses.first().map(|a| a.to_string()));
|
|
found = Some((*needle, addr.into_iter().collect::<Vec<_>>(), 0u64));
|
|
break;
|
|
}
|
|
}
|
|
if found.is_none() {
|
|
let storage = self.storage.lock().await;
|
|
let entries = storage.find_any_in_n2_n3(&all_needles)?;
|
|
if let Some((found_id, _reporter, _level)) = entries.first() {
|
|
drop(storage);
|
|
let address = self.resolve_address(found_id).await.unwrap_or(None);
|
|
found = Some((*found_id, address.into_iter().collect::<Vec<_>>(), 0u64));
|
|
}
|
|
}
|
|
found
|
|
};
|
|
|
|
// If we found content (post or blob) but no node, still respond immediately
|
|
let content_found = post_holder.is_some() || blob_holder.is_some();
|
|
|
|
if let Some((found_id, addresses, _updated_at)) = local_result {
|
|
let wide_referral = self.pick_random_wide_referral(&from_peer).await;
|
|
|
|
let resp = WormResponsePayload {
|
|
worm_id: payload.worm_id,
|
|
found: true,
|
|
found_id: Some(found_id),
|
|
addresses,
|
|
reporter: Some(self.our_node_id),
|
|
hop: None,
|
|
wide_referral,
|
|
post_holder,
|
|
blob_holder,
|
|
};
|
|
write_typed_message(&mut send, MessageType::WormResponse, &resp).await?;
|
|
send.finish()?;
|
|
return Ok(());
|
|
}
|
|
|
|
if content_found {
|
|
let wide_referral = self.pick_random_wide_referral(&from_peer).await;
|
|
|
|
let resp = WormResponsePayload {
|
|
worm_id: payload.worm_id,
|
|
found: false,
|
|
found_id: None,
|
|
addresses: vec![],
|
|
reporter: Some(self.our_node_id),
|
|
hop: None,
|
|
wide_referral,
|
|
post_holder,
|
|
blob_holder,
|
|
};
|
|
write_typed_message(&mut send, MessageType::WormResponse, &resp).await?;
|
|
send.finish()?;
|
|
return Ok(());
|
|
}
|
|
|
|
// Step 2: If ttl > 0, fan out to our own connected peers with ttl=0
|
|
if payload.ttl > 0 {
|
|
let visited_set: HashSet<NodeId> = payload.visited.iter().copied().collect();
|
|
let peer_conns: Vec<(NodeId, iroh::endpoint::Connection)> = self
|
|
.connections
|
|
.iter()
|
|
.filter(|(nid, _)| !visited_set.contains(*nid) && **nid != from_peer)
|
|
.map(|(nid, pc)| (*nid, pc.connection.clone()))
|
|
.collect();
|
|
|
|
if !peer_conns.is_empty() {
|
|
let fan_payload = WormQueryPayload {
|
|
worm_id: payload.worm_id,
|
|
target: payload.target,
|
|
needle_peers: payload.needle_peers.clone(),
|
|
ttl: 0,
|
|
visited: payload.visited.clone(),
|
|
post_id: payload.post_id.clone(),
|
|
blob_id: payload.blob_id,
|
|
};
|
|
|
|
let (hit, _referrals) = tokio::time::timeout(
|
|
std::time::Duration::from_millis(WORM_FAN_OUT_TIMEOUT_MS),
|
|
Self::fan_out_worm_query_all(&peer_conns, &fan_payload),
|
|
)
|
|
.await
|
|
.unwrap_or((None, vec![]));
|
|
|
|
if let Some(wr) = hit {
|
|
let wide_referral = self.pick_random_wide_referral(&from_peer).await;
|
|
let resp = WormResponsePayload {
|
|
worm_id: payload.worm_id,
|
|
found: true,
|
|
found_id: Some(wr.node_id),
|
|
addresses: wr.addresses,
|
|
reporter: Some(wr.reporter),
|
|
hop: None,
|
|
wide_referral,
|
|
post_holder: wr.post_holder,
|
|
blob_holder: wr.blob_holder,
|
|
};
|
|
write_typed_message(&mut send, MessageType::WormResponse, &resp).await?;
|
|
send.finish()?;
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Not found — respond with wide_referral for the originator's bloom round
|
|
let wide_referral = self.pick_random_wide_referral(&from_peer).await;
|
|
let resp = WormResponsePayload {
|
|
worm_id: payload.worm_id,
|
|
found: false,
|
|
found_id: None,
|
|
addresses: vec![],
|
|
reporter: None,
|
|
hop: None,
|
|
wide_referral,
|
|
post_holder: None,
|
|
blob_holder: None,
|
|
};
|
|
write_typed_message(&mut send, MessageType::WormResponse, &resp).await?;
|
|
send.finish()?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Pick a random wide-connected peer to include as a referral in worm responses.
|
|
/// Returns (node_id, address_string) if a suitable peer with a known address is found.
|
|
async fn pick_random_wide_referral(&self, exclude: &NodeId) -> Option<(NodeId, String)> {
|
|
// Prefer wide-slot peers, but any connected peer with a known address works
|
|
let candidates: Vec<(NodeId, PeerSlotKind)> = self
|
|
.connections
|
|
.iter()
|
|
.filter(|(nid, _)| *nid != exclude && **nid != self.our_node_id)
|
|
.map(|(nid, pc)| (*nid, pc.slot_kind))
|
|
.collect();
|
|
|
|
if candidates.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
// Prefer wide peers
|
|
let wide: Vec<_> = candidates
|
|
.iter()
|
|
.filter(|(_, kind)| *kind == PeerSlotKind::Wide)
|
|
.collect();
|
|
|
|
// Shuffle candidates to pick randomly
|
|
let ordered: Vec<&(NodeId, PeerSlotKind)> = if !wide.is_empty() {
|
|
wide
|
|
} else {
|
|
candidates.iter().collect()
|
|
};
|
|
|
|
// Try each candidate until we find one with a known address
|
|
let storage = self.storage.lock().await;
|
|
for candidate in ordered {
|
|
let nid = candidate.0;
|
|
if let Ok(Some(rec)) = storage.get_peer_record(&nid) {
|
|
if let Some(addr) = rec.addresses.first() {
|
|
return Some((nid, addr.to_string()));
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Disconnect a peer, cleaning up N2/N3 entries.
|
|
pub async fn disconnect_peer(&mut self, peer_id: &NodeId) {
|
|
if let Some(pc) = self.connections.remove(peer_id) {
|
|
drop(pc);
|
|
}
|
|
|
|
// Mark disconnected in referral list (anchor-side)
|
|
self.mark_referral_disconnected(peer_id);
|
|
|
|
let storage = self.storage.lock().await;
|
|
// Remove their N2 contributions (their N1 share → our N2)
|
|
let _ = storage.clear_peer_n2(peer_id);
|
|
// Remove their N3 contributions (their N2 share → our N3)
|
|
let _ = storage.clear_peer_n3(peer_id);
|
|
// Remove from active mesh peers (but keep in peers table for reconnection)
|
|
let _ = storage.remove_mesh_peer(peer_id);
|
|
// Mark social route as disconnected
|
|
if storage.has_social_route(peer_id).unwrap_or(false) {
|
|
let _ = storage.set_social_route_status(peer_id, SocialStatus::Disconnected);
|
|
}
|
|
|
|
let remaining = self.connections.len();
|
|
debug!(peer = hex::encode(peer_id), remaining, "Disconnected peer");
|
|
self.log_activity(ActivityLevel::Info, ActivityCategory::Connection, format!("Disconnected, {} remaining", remaining), Some(*peer_id));
|
|
|
|
// If mesh is completely empty, trigger immediate recovery
|
|
if self.connections.is_empty() {
|
|
info!("Mesh empty, triggering recovery");
|
|
self.log_activity(ActivityLevel::Warn, ActivityCategory::Connection, "Mesh empty".into(), None);
|
|
self.notify_recovery();
|
|
}
|
|
}
|
|
|
|
/// Notify watchers that a previously disconnected peer has reconnected.
|
|
/// Takes pre-gathered data to avoid holding &Storage across await points.
|
|
async fn notify_watchers(&self, watchers: Vec<NodeId>, peer_id: &NodeId, route: &SocialRouteEntry) {
|
|
let payload = SocialAddressUpdatePayload {
|
|
node_id: *peer_id,
|
|
addresses: route.addresses.iter().map(|a: &std::net::SocketAddr| a.to_string()).collect(),
|
|
peer_addresses: route.peer_addresses.clone(),
|
|
};
|
|
|
|
let mut notified = 0;
|
|
for watcher in &watchers {
|
|
if let Some(pc) = self.connections.get(watcher) {
|
|
let result = async {
|
|
let mut send = pc.connection.open_uni().await?;
|
|
write_typed_message(&mut send, MessageType::SocialAddressUpdate, &payload).await?;
|
|
send.finish()?;
|
|
anyhow::Ok(())
|
|
}.await;
|
|
if result.is_ok() {
|
|
notified += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
if notified > 0 {
|
|
info!(peer = hex::encode(peer_id), notified, "Notified watchers of reconnection");
|
|
}
|
|
}
|
|
|
|
/// Send a SocialCheckin to a specific peer. Returns their checkin reply info if successful.
|
|
pub async fn send_social_checkin(
|
|
&self,
|
|
peer_id: &NodeId,
|
|
our_node_id: &NodeId,
|
|
our_addresses: &[String],
|
|
our_peer_addresses: &[PeerWithAddress],
|
|
) -> anyhow::Result<SocialCheckinPayload> {
|
|
let pc = self.connections.get(peer_id)
|
|
.ok_or_else(|| anyhow::anyhow!("not connected to peer"))?;
|
|
|
|
let payload = SocialCheckinPayload {
|
|
node_id: *our_node_id,
|
|
addresses: our_addresses.to_vec(),
|
|
peer_addresses: our_peer_addresses.to_vec(),
|
|
};
|
|
|
|
let (mut send, mut recv) = pc.connection.open_bi().await?;
|
|
write_typed_message(&mut send, MessageType::SocialCheckin, &payload).await?;
|
|
send.finish()?;
|
|
|
|
let msg_type = read_message_type(&mut recv).await?;
|
|
if msg_type != MessageType::SocialCheckin {
|
|
anyhow::bail!("expected SocialCheckin reply, got {:?}", msg_type);
|
|
}
|
|
let reply: SocialCheckinPayload = read_payload(&mut recv, MAX_PAYLOAD).await?;
|
|
Ok(reply)
|
|
}
|
|
|
|
/// Rebalance connection slots: remove dead connections, prune stale N2/N3 entries.
|
|
/// Returns list of newly connected peer IDs (caller should spawn run_mesh_streams for them).
|
|
pub async fn rebalance_slots(&mut self) -> anyhow::Result<Vec<NodeId>> {
|
|
self.log_activity(ActivityLevel::Info, ActivityCategory::Rebalance, "Rebalance started".into(), None);
|
|
|
|
// 1. Remove dead + zombie connections
|
|
let mut dead = Vec::new();
|
|
let now = now_ms();
|
|
for (peer_id, pc) in &self.connections {
|
|
if pc.connection.close_reason().is_some() {
|
|
dead.push(*peer_id);
|
|
} else if now.saturating_sub(pc.last_activity.load(Ordering::Relaxed)) > ZOMBIE_TIMEOUT_MS {
|
|
let idle_secs = now.saturating_sub(pc.last_activity.load(Ordering::Relaxed)) / 1000;
|
|
info!(peer = hex::encode(peer_id), idle_secs, "Zombie connection detected (no activity)");
|
|
self.log_activity(ActivityLevel::Warn, ActivityCategory::Rebalance, format!("Zombie (idle {}s)", idle_secs), Some(*peer_id));
|
|
dead.push(*peer_id);
|
|
}
|
|
}
|
|
|
|
for peer_id in &dead {
|
|
info!(peer = hex::encode(peer_id), "Removing dead connection");
|
|
self.log_activity(ActivityLevel::Info, ActivityCategory::Rebalance, "Removed dead connection".into(), Some(*peer_id));
|
|
self.disconnect_peer(peer_id).await;
|
|
}
|
|
|
|
// 2. Prune stale N2/N3 entries (5 hours) + stale watchers (30 days)
|
|
{
|
|
let storage = self.storage.lock().await;
|
|
let pruned = storage.prune_n2_n3(5 * 60 * 60 * 1000)?;
|
|
if pruned > 0 {
|
|
info!(pruned, "Pruned stale N2/N3 entries");
|
|
}
|
|
let _ = storage.prune_stale_watchers(WATCHER_EXPIRY_MS);
|
|
}
|
|
|
|
// 3. Diversity scoring: find low-diversity peers for potential eviction
|
|
{
|
|
let storage = self.storage.lock().await;
|
|
let connected: Vec<NodeId> = self.connections.keys().copied().collect();
|
|
let mut zero_diversity = Vec::new();
|
|
for peer_id in &connected {
|
|
let unique = storage.count_unique_n2_for_reporter(peer_id, &[]).unwrap_or(0);
|
|
if unique == 0 {
|
|
zero_diversity.push(*peer_id);
|
|
}
|
|
}
|
|
if !zero_diversity.is_empty() {
|
|
debug!(count = zero_diversity.len(), "Peers with zero unique N2 contributions");
|
|
}
|
|
}
|
|
|
|
let mut newly_connected: Vec<NodeId> = Vec::new();
|
|
|
|
// Priority 0 (NEW): Reconnect preferred peers
|
|
{
|
|
let preferred_peers = {
|
|
let storage = self.storage.lock().await;
|
|
storage.list_preferred_peers().unwrap_or_default()
|
|
};
|
|
|
|
let now = now_ms();
|
|
for peer_id in &preferred_peers {
|
|
if self.connections.contains_key(peer_id) || *peer_id == self.our_node_id {
|
|
continue;
|
|
}
|
|
// Prune preferred peers unreachable for 7+ days
|
|
if let Some(&failed_at) = self.unreachable_peers.get(peer_id) {
|
|
if now.saturating_sub(failed_at) > PREFERRED_UNREACHABLE_PRUNE_MS {
|
|
info!(peer = hex::encode(peer_id), "Removing preferred peer unreachable for 7 days+");
|
|
let storage = self.storage.lock().await;
|
|
let _ = storage.remove_preferred_peer(peer_id);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Evict lowest-diversity non-preferred peer if at capacity
|
|
let total_slots = self.preferred_slots + self.local_slots + self.wide_slots;
|
|
if self.connections.len() >= total_slots {
|
|
let evict_candidate = self.find_non_preferred_eviction_candidate().await;
|
|
if let Some(evict_id) = evict_candidate {
|
|
info!(
|
|
evicting = hex::encode(evict_id),
|
|
for_preferred = hex::encode(peer_id),
|
|
"Evicting non-preferred peer for preferred reconnection"
|
|
);
|
|
self.log_activity(ActivityLevel::Info, ActivityCategory::Rebalance, format!("Evicting {} for preferred {}", &hex::encode(evict_id)[..8], &hex::encode(peer_id)[..8]), Some(evict_id));
|
|
self.disconnect_peer(&evict_id).await;
|
|
} else {
|
|
debug!(peer = hex::encode(peer_id), "No non-preferred peer to evict");
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if let Err(e) = self.reconnect_preferred(peer_id).await {
|
|
debug!(peer = hex::encode(peer_id), error = %e, "Preferred reconnection failed");
|
|
} else if self.connections.contains_key(peer_id) {
|
|
newly_connected.push(*peer_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Priority 1+2: Fill empty local slots with diverse candidates
|
|
let local_count = self.count_kind(PeerSlotKind::Local);
|
|
if local_count < self.local_slots {
|
|
let candidates: Vec<(NodeId, Option<String>)> = {
|
|
let storage = self.storage.lock().await;
|
|
let mut cands = Vec::new();
|
|
|
|
// Priority 1: reconnect recently-dead non-preferred peers
|
|
for peer_id in &dead {
|
|
if *peer_id == self.our_node_id {
|
|
continue;
|
|
}
|
|
// Skip preferred peers — handled above
|
|
if storage.is_preferred_peer(peer_id).unwrap_or(false) {
|
|
continue;
|
|
}
|
|
if let Ok(Some(rec)) = storage.get_peer_record(peer_id) {
|
|
let addr = rec.addresses.first().map(|a| a.to_string());
|
|
cands.push((*peer_id, addr));
|
|
}
|
|
}
|
|
|
|
// Priority 2: handled by growth loop (reactive, one-at-a-time)
|
|
cands
|
|
};
|
|
|
|
let slots_available = self.local_slots - local_count;
|
|
let to_connect = candidates.len().min(slots_available);
|
|
if to_connect > 0 {
|
|
debug!(candidates = candidates.len(), connecting = to_connect, "Filling local slots");
|
|
}
|
|
|
|
for (peer_id, addr_str) in candidates.into_iter().take(slots_available) {
|
|
let resolved_addr = if let Some(addr_s) = addr_str {
|
|
Some(addr_s)
|
|
} else {
|
|
match self.resolve_address(&peer_id).await {
|
|
Ok(addr) => addr,
|
|
Err(_) => None,
|
|
}
|
|
};
|
|
|
|
if let Some(addr_s) = resolved_addr {
|
|
let endpoint_id = match iroh::EndpointId::from_bytes(&peer_id) {
|
|
Ok(eid) => eid,
|
|
Err(_) => continue,
|
|
};
|
|
let mut addr = iroh::EndpointAddr::from(endpoint_id);
|
|
if let Ok(sock) = addr_s.parse::<std::net::SocketAddr>() {
|
|
addr = addr.with_ip_addr(sock);
|
|
}
|
|
match self.connect_to(peer_id, addr, PeerSlotKind::Local).await {
|
|
Ok(()) => {
|
|
info!(peer = hex::encode(peer_id), "Auto-connected to diverse peer");
|
|
newly_connected.push(peer_id);
|
|
}
|
|
Err(e) => {
|
|
debug!(peer = hex::encode(peer_id), error = %e, "Auto-connect failed");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initial exchange for newly connected peers is done by Network::rebalance()
|
|
// outside the conn_mgr lock to avoid blocking other operations.
|
|
|
|
// 5. Reap idle session connections (keeps anchor + referral sessions alive)
|
|
self.reap_idle_sessions(SESSION_IDLE_TIMEOUT_MS).await;
|
|
|
|
// 6. Prune stale relay intro dedup entries
|
|
{
|
|
let now = now_ms();
|
|
if self.seen_intros.len() > 500 {
|
|
self.seen_intros
|
|
.retain(|_, ts| now - *ts < RELAY_INTRO_DEDUP_EXPIRY_MS);
|
|
}
|
|
}
|
|
|
|
if !dead.is_empty() {
|
|
info!(removed = dead.len(), "Rebalance complete");
|
|
self.log_activity(ActivityLevel::Info, ActivityCategory::Rebalance, format!("Complete, removed {}", dead.len()), None);
|
|
} else {
|
|
self.log_activity(ActivityLevel::Info, ActivityCategory::Rebalance, "Complete, no changes".into(), None);
|
|
}
|
|
|
|
// Backstop: signal growth loop to fill any remaining local slots
|
|
self.notify_growth();
|
|
|
|
Ok(newly_connected)
|
|
}
|
|
|
|
/// Find the lowest-diversity non-preferred peer to evict.
|
|
async fn find_non_preferred_eviction_candidate(&self) -> Option<NodeId> {
|
|
let storage = self.storage.lock().await;
|
|
let mut worst: Option<(NodeId, usize)> = None;
|
|
for (peer_id, mc) in &self.connections {
|
|
if mc.slot_kind == PeerSlotKind::Preferred {
|
|
continue; // Never evict preferred
|
|
}
|
|
let unique = storage.count_unique_n2_for_reporter(peer_id, &[]).unwrap_or(0);
|
|
match &worst {
|
|
None => worst = Some((*peer_id, unique)),
|
|
Some((_, worst_unique)) if unique < *worst_unique => worst = Some((*peer_id, unique)),
|
|
_ => {}
|
|
}
|
|
}
|
|
worst.map(|(nid, _)| nid)
|
|
}
|
|
|
|
pub fn is_connected(&self, peer_id: &NodeId) -> bool {
|
|
self.connections.contains_key(peer_id)
|
|
}
|
|
|
|
pub fn connected_peers(&self) -> Vec<NodeId> {
|
|
self.connections.keys().cloned().collect()
|
|
}
|
|
|
|
pub fn connection_count(&self) -> usize {
|
|
self.connections.len()
|
|
}
|
|
|
|
/// Get connection info for display: (node_id, slot_kind, connected_at)
|
|
/// Get a reference to the connections map (for Network to access connections).
|
|
pub fn connections_ref(&self) -> &HashMap<NodeId, MeshConnection> {
|
|
&self.connections
|
|
}
|
|
|
|
pub fn connection_info(&self) -> Vec<(NodeId, PeerSlotKind, u64)> {
|
|
self.connections
|
|
.values()
|
|
.map(|pc| (pc.node_id, pc.slot_kind, pc.connected_at))
|
|
.collect()
|
|
}
|
|
|
|
fn count_kind(&self, kind: PeerSlotKind) -> usize {
|
|
self.connections.values().filter(|pc| pc.slot_kind == kind).count()
|
|
}
|
|
|
|
// ---- Preferred peer negotiation ----
|
|
|
|
/// Request bilateral preferred peer status with a connected mesh peer.
|
|
/// On success, both sides persist the agreement and upgrade the slot.
|
|
pub async fn request_prefer(&mut self, peer_id: &NodeId) -> anyhow::Result<bool> {
|
|
let pc = self.connections.get(peer_id)
|
|
.ok_or_else(|| anyhow::anyhow!("peer not connected"))?;
|
|
|
|
// Check if we have room
|
|
let preferred_count = self.count_kind(PeerSlotKind::Preferred);
|
|
if preferred_count >= self.preferred_slots {
|
|
anyhow::bail!("preferred slots full ({}/{})", preferred_count, self.preferred_slots);
|
|
}
|
|
|
|
let request = MeshPreferPayload {
|
|
requesting: true,
|
|
accepted: false,
|
|
reject_reason: None,
|
|
};
|
|
|
|
let (mut send, mut recv) = pc.connection.open_bi().await?;
|
|
write_typed_message(&mut send, MessageType::MeshPrefer, &request).await?;
|
|
send.finish()?;
|
|
|
|
let msg_type = read_message_type(&mut recv).await?;
|
|
if msg_type != MessageType::MeshPrefer {
|
|
anyhow::bail!("expected MeshPrefer response, got {:?}", msg_type);
|
|
}
|
|
let response: MeshPreferPayload = read_payload(&mut recv, 4096).await?;
|
|
|
|
if response.accepted {
|
|
// Persist agreement
|
|
let storage = self.storage.lock().await;
|
|
storage.add_preferred_peer(peer_id)?;
|
|
storage.add_mesh_peer(peer_id, PeerSlotKind::Preferred, 100)?;
|
|
drop(storage);
|
|
|
|
// Upgrade slot in-memory
|
|
if let Some(mc) = self.connections.get_mut(peer_id) {
|
|
mc.slot_kind = PeerSlotKind::Preferred;
|
|
}
|
|
info!(peer = hex::encode(peer_id), "Preferred peer agreement established");
|
|
Ok(true)
|
|
} else {
|
|
debug!(
|
|
peer = hex::encode(peer_id),
|
|
reason = ?response.reject_reason,
|
|
"Preferred peer request rejected"
|
|
);
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
/// Handle an incoming MeshPrefer request from a connected peer.
|
|
pub async fn handle_mesh_prefer(
|
|
&mut self,
|
|
from_peer: NodeId,
|
|
mut send: iroh::endpoint::SendStream,
|
|
mut recv: iroh::endpoint::RecvStream,
|
|
) -> anyhow::Result<()> {
|
|
let request: MeshPreferPayload = read_payload(&mut recv, 4096).await?;
|
|
|
|
if !request.requesting {
|
|
// Not a request — ignore
|
|
return Ok(());
|
|
}
|
|
|
|
// Check if we have room for a preferred peer
|
|
let preferred_count = self.count_kind(PeerSlotKind::Preferred);
|
|
let can_accept = preferred_count < self.preferred_slots
|
|
&& self.connections.contains_key(&from_peer);
|
|
|
|
let response = if can_accept {
|
|
// Persist agreement
|
|
let storage = self.storage.lock().await;
|
|
storage.add_preferred_peer(&from_peer)?;
|
|
storage.add_mesh_peer(&from_peer, PeerSlotKind::Preferred, 100)?;
|
|
drop(storage);
|
|
|
|
// Upgrade slot in-memory
|
|
if let Some(mc) = self.connections.get_mut(&from_peer) {
|
|
mc.slot_kind = PeerSlotKind::Preferred;
|
|
}
|
|
|
|
info!(peer = hex::encode(from_peer), "Accepted preferred peer request");
|
|
|
|
MeshPreferPayload {
|
|
requesting: false,
|
|
accepted: true,
|
|
reject_reason: None,
|
|
}
|
|
} else {
|
|
let reason = if !self.connections.contains_key(&from_peer) {
|
|
"not connected".to_string()
|
|
} else {
|
|
format!("preferred slots full ({}/{})", preferred_count, self.preferred_slots)
|
|
};
|
|
debug!(peer = hex::encode(from_peer), reason = %reason, "Rejecting preferred peer request");
|
|
MeshPreferPayload {
|
|
requesting: false,
|
|
accepted: false,
|
|
reject_reason: Some(reason),
|
|
}
|
|
};
|
|
|
|
write_typed_message(&mut send, MessageType::MeshPrefer, &response).await?;
|
|
send.finish()?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Reconnect a preferred peer via relay introduction if direct connect fails.
|
|
pub async fn reconnect_preferred(&mut self, peer_id: &NodeId) -> anyhow::Result<()> {
|
|
if self.connections.contains_key(peer_id) {
|
|
return Ok(()); // Already connected
|
|
}
|
|
|
|
// Try direct connect first (from peers table or social route)
|
|
let addr_str = if !self.is_likely_unreachable(peer_id) {
|
|
let storage = self.storage.lock().await;
|
|
let addr = storage.get_peer_record(peer_id)?
|
|
.and_then(|r| r.addresses.first().map(|a| a.to_string()))
|
|
.or_else(|| {
|
|
storage.get_social_route(peer_id).ok().flatten()
|
|
.and_then(|r| r.addresses.first().map(|a| a.to_string()))
|
|
});
|
|
drop(storage);
|
|
addr
|
|
} else {
|
|
None
|
|
};
|
|
|
|
if let Some(addr_s) = addr_str {
|
|
let endpoint_id = iroh::EndpointId::from_bytes(peer_id)?;
|
|
let mut addr = iroh::EndpointAddr::from(endpoint_id);
|
|
if let Ok(sock) = addr_s.parse::<std::net::SocketAddr>() {
|
|
addr = addr.with_ip_addr(sock);
|
|
}
|
|
match tokio::time::timeout(
|
|
std::time::Duration::from_millis(HOLE_PUNCH_TIMEOUT_MS),
|
|
self.connect_to(*peer_id, addr, PeerSlotKind::Preferred),
|
|
).await {
|
|
Ok(Ok(())) => {
|
|
self.mark_reachable(peer_id);
|
|
info!(peer = hex::encode(peer_id), "Preferred peer reconnected directly");
|
|
return Ok(());
|
|
}
|
|
Ok(Err(e)) => {
|
|
debug!(peer = hex::encode(peer_id), error = %e, "Direct reconnect failed, trying relay");
|
|
self.mark_unreachable(peer_id);
|
|
}
|
|
Err(_) => {
|
|
debug!(peer = hex::encode(peer_id), "Direct reconnect timed out, trying relay");
|
|
self.mark_unreachable(peer_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try relay introduction (with timeout to avoid holding lock forever)
|
|
let relays = self.find_relays_for(peer_id).await;
|
|
for (relay_peer, ttl) in relays {
|
|
let introduce_result = match tokio::time::timeout(
|
|
std::time::Duration::from_millis(RELAY_INTRO_TIMEOUT_MS),
|
|
self.send_relay_introduce(&relay_peer, peer_id, ttl),
|
|
).await {
|
|
Ok(r) => r,
|
|
Err(_) => {
|
|
debug!(relay = hex::encode(relay_peer), "Relay introduce timed out");
|
|
continue;
|
|
}
|
|
};
|
|
match introduce_result {
|
|
Ok(result) if result.accepted => {
|
|
let our_profile = self.our_nat_profile();
|
|
let peer_profile = {
|
|
let s = self.storage.lock().await;
|
|
s.get_peer_nat_profile(peer_id)
|
|
};
|
|
if let Some(conn) = hole_punch_with_scanning(&self.endpoint, peer_id, &result.target_addresses, our_profile, peer_profile).await {
|
|
// Register as preferred mesh peer
|
|
self.register_connection(*peer_id, conn, &[], PeerSlotKind::Preferred).await;
|
|
self.mark_reachable(peer_id);
|
|
info!(
|
|
peer = hex::encode(peer_id),
|
|
relay = hex::encode(relay_peer),
|
|
"Preferred peer reconnected via relay hole punch"
|
|
);
|
|
return Ok(());
|
|
}
|
|
}
|
|
Ok(_) => {} // Not accepted, try next relay
|
|
Err(e) => {
|
|
debug!(relay = hex::encode(relay_peer), error = %e, "Relay introduce failed");
|
|
}
|
|
}
|
|
}
|
|
|
|
debug!(peer = hex::encode(peer_id), "Could not reconnect preferred peer");
|
|
Ok(())
|
|
}
|
|
|
|
// ---- Session connection management ----
|
|
|
|
/// Add a session connection. Evicts oldest idle session if at capacity.
|
|
pub fn add_session(
|
|
&mut self,
|
|
node_id: NodeId,
|
|
connection: iroh::endpoint::Connection,
|
|
reach_method: SessionReachMethod,
|
|
remote_addr: Option<std::net::SocketAddr>,
|
|
) {
|
|
let now = now_ms();
|
|
|
|
// Evict oldest idle session if at capacity
|
|
if self.sessions.len() >= self.session_slots && !self.sessions.contains_key(&node_id) {
|
|
let oldest = self
|
|
.sessions
|
|
.iter()
|
|
.min_by_key(|(_, s)| s.last_active_at)
|
|
.map(|(nid, _)| *nid);
|
|
if let Some(old_nid) = oldest {
|
|
debug!(peer = hex::encode(old_nid), "Evicting idle session for new session");
|
|
self.sessions.remove(&old_nid);
|
|
}
|
|
}
|
|
|
|
self.sessions.insert(
|
|
node_id,
|
|
SessionConnection {
|
|
node_id,
|
|
connection,
|
|
created_at: now,
|
|
last_active_at: now,
|
|
reach_method,
|
|
remote_addr,
|
|
},
|
|
);
|
|
info!(peer = hex::encode(node_id), method = %reach_method, "Added session connection");
|
|
}
|
|
|
|
/// Get a session connection (does NOT touch last_active_at — caller should call touch_session).
|
|
pub fn get_session(&self, node_id: &NodeId) -> Option<&SessionConnection> {
|
|
self.sessions.get(node_id)
|
|
}
|
|
|
|
/// Remove a session connection.
|
|
pub fn remove_session(&mut self, node_id: &NodeId) {
|
|
if self.sessions.remove(node_id).is_some() {
|
|
debug!(peer = hex::encode(node_id), "Removed session connection");
|
|
}
|
|
}
|
|
|
|
/// Update last_active_at for a session.
|
|
pub fn touch_session(&mut self, node_id: &NodeId) {
|
|
if let Some(session) = self.sessions.get_mut(node_id) {
|
|
session.last_active_at = now_ms();
|
|
}
|
|
}
|
|
|
|
/// Close sessions idle longer than the given timeout.
|
|
pub async fn reap_idle_sessions(&mut self, idle_timeout_ms: u64) {
|
|
let now = now_ms();
|
|
|
|
// Build set of sessions that have a reason to stay alive
|
|
let mut keep_alive: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
|
|
|
|
// Anchor side: peers on our referral list need their session kept
|
|
for nid in self.referral_list.keys() {
|
|
if self.sessions.contains_key(nid) && !self.connections.contains_key(nid) {
|
|
keep_alive.insert(*nid);
|
|
}
|
|
}
|
|
|
|
// Client side: known anchors we're session-connected to (mesh was full)
|
|
{
|
|
let storage = self.storage.lock().await;
|
|
for nid in self.sessions.keys() {
|
|
if storage.is_peer_anchor(nid).unwrap_or(false) && !self.connections.contains_key(nid) {
|
|
keep_alive.insert(*nid);
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut reaped = Vec::new();
|
|
for (nid, session) in &self.sessions {
|
|
if keep_alive.contains(nid) {
|
|
continue; // Session has a reason to stay alive
|
|
}
|
|
if now.saturating_sub(session.last_active_at) > idle_timeout_ms {
|
|
reaped.push(*nid);
|
|
}
|
|
}
|
|
for nid in &reaped {
|
|
self.sessions.remove(nid);
|
|
}
|
|
if !reaped.is_empty() {
|
|
info!(count = reaped.len(), kept = keep_alive.len(), "Reaped idle sessions");
|
|
}
|
|
}
|
|
|
|
/// Get session info for display: (node_id, reach_method, last_active_at)
|
|
pub fn session_info(&self) -> Vec<(NodeId, SessionReachMethod, u64)> {
|
|
self.sessions
|
|
.values()
|
|
.map(|s| (s.node_id, s.reach_method, s.last_active_at))
|
|
.collect()
|
|
}
|
|
|
|
/// Check if a node has a session connection.
|
|
pub fn has_session(&self, node_id: &NodeId) -> bool {
|
|
self.sessions.contains_key(node_id)
|
|
}
|
|
|
|
/// Check if a node is connected via mesh OR session.
|
|
pub fn is_connected_or_session(&self, node_id: &NodeId) -> bool {
|
|
self.connections.contains_key(node_id) || self.sessions.contains_key(node_id)
|
|
}
|
|
|
|
/// Get list of session peer NodeIds.
|
|
pub fn session_peer_ids(&self) -> Vec<NodeId> {
|
|
self.sessions.keys().copied().collect()
|
|
}
|
|
|
|
/// Get the active relay pipe count reference (for relay pipe tasks).
|
|
pub fn active_relay_pipes(&self) -> &Arc<AtomicU64> {
|
|
&self.active_relay_pipes
|
|
}
|
|
|
|
/// Check if we can accept more relay pipes.
|
|
pub fn can_accept_relay_pipe(&self) -> bool {
|
|
self.active_relay_pipes.load(Ordering::Relaxed) < self.max_relay_pipes as u64
|
|
}
|
|
|
|
/// Get our node ID.
|
|
pub fn our_node_id(&self) -> &NodeId {
|
|
&self.our_node_id
|
|
}
|
|
|
|
/// Get a connection (mesh or session) to a peer, if any.
|
|
pub(crate) fn get_any_connection(&self, peer: &NodeId) -> Option<iroh::endpoint::Connection> {
|
|
self.connections.get(peer)
|
|
.map(|pc| pc.connection.clone())
|
|
.or_else(|| self.sessions.get(peer).map(|s| s.connection.clone()))
|
|
}
|
|
|
|
/// Get a clone of the storage Arc (for standalone exchange functions).
|
|
pub fn storage_ref(&self) -> Arc<Mutex<Storage>> {
|
|
Arc::clone(&self.storage)
|
|
}
|
|
|
|
// ---- NAT reachability tracking ----
|
|
|
|
/// 30 minutes — how long we consider a "unreachable" verdict valid
|
|
const UNREACHABLE_EXPIRY_MS: u64 = 1_800_000;
|
|
|
|
/// Mark a peer as directly reachable (clear any unreachable flag).
|
|
pub fn mark_reachable(&mut self, node_id: &NodeId) {
|
|
if self.unreachable_peers.remove(node_id).is_some() {
|
|
debug!(peer = hex::encode(node_id), "Peer now marked directly reachable");
|
|
}
|
|
}
|
|
|
|
/// Mark a peer as not directly reachable (behind NAT or offline).
|
|
pub fn mark_unreachable(&mut self, node_id: &NodeId) {
|
|
let now = now_ms();
|
|
self.unreachable_peers.insert(*node_id, now);
|
|
debug!(peer = hex::encode(node_id), "Peer marked unreachable (direct failed)");
|
|
}
|
|
|
|
/// Behavioral NAT filtering inference: update peer's profile based on connection outcome.
|
|
/// Call after a successful hole punch to refine filtering classification.
|
|
/// `used_scanning` = true if scanning was required (standard punch failed first).
|
|
pub async fn infer_nat_filtering(&self, node_id: &NodeId, used_scanning: bool) {
|
|
let s = self.storage.lock().await;
|
|
let mut profile = s.get_peer_nat_profile(node_id);
|
|
if profile.mapping == crate::types::NatMapping::EndpointDependent {
|
|
if used_scanning {
|
|
// Scanning succeeded where standard punch failed → port-restricted
|
|
if profile.filtering != crate::types::NatFiltering::PortRestricted {
|
|
profile.filtering = crate::types::NatFiltering::PortRestricted;
|
|
let _ = s.set_peer_nat_profile(node_id, &profile);
|
|
info!(peer = hex::encode(node_id), "NAT filtering inferred: port-restricted (from scanning outcome)");
|
|
}
|
|
} else {
|
|
// Standard punch succeeded despite EDM → open/address-restricted
|
|
if profile.filtering != crate::types::NatFiltering::Open {
|
|
profile.filtering = crate::types::NatFiltering::Open;
|
|
let _ = s.set_peer_nat_profile(node_id, &profile);
|
|
info!(peer = hex::encode(node_id), "NAT filtering inferred: open (standard punch succeeded)");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check if a peer is likely unreachable directly (failed recently).
|
|
pub fn is_likely_unreachable(&self, node_id: &NodeId) -> bool {
|
|
if let Some(&failed_at) = self.unreachable_peers.get(node_id) {
|
|
now_ms().saturating_sub(failed_at) < Self::UNREACHABLE_EXPIRY_MS
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
// ---- Relay introduction ----
|
|
|
|
/// Find relay peers that can introduce us to a target.
|
|
/// Returns up to 3 candidates as (relay_node_id, ttl) where ttl=0 means relay
|
|
/// knows target directly (N2), ttl=1 means relay chains through their peer (N3).
|
|
pub async fn find_relays_for(&self, target: &NodeId) -> Vec<(NodeId, u8)> {
|
|
let mut candidates = Vec::new();
|
|
let storage = self.storage.lock().await;
|
|
|
|
// Step 1 (NEW): Check target's preferred_tree from social_routes (~100 NodeIds)
|
|
// Intersect with our connections → TTL=0 candidates (they know target or are stably nearby)
|
|
if let Ok(Some(route)) = storage.get_social_route(target) {
|
|
if !route.preferred_tree.is_empty() {
|
|
// Prefer our own preferred peers first within the tree
|
|
for tree_node in &route.preferred_tree {
|
|
if candidates.len() >= 3 {
|
|
break;
|
|
}
|
|
if let Some(mc) = self.connections.get(tree_node) {
|
|
if mc.slot_kind == PeerSlotKind::Preferred
|
|
&& !candidates.iter().any(|(nid, _)| nid == tree_node)
|
|
{
|
|
candidates.push((*tree_node, 0));
|
|
}
|
|
}
|
|
}
|
|
// Then any connected tree node
|
|
for tree_node in &route.preferred_tree {
|
|
if candidates.len() >= 3 {
|
|
break;
|
|
}
|
|
if self.connections.contains_key(tree_node)
|
|
&& !candidates.iter().any(|(nid, _)| nid == tree_node)
|
|
{
|
|
candidates.push((*tree_node, 0));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 2: Our mesh peers that have target in their N1 (our N2 entry tagged to them)
|
|
if candidates.len() < 3 {
|
|
if let Ok(reporters) = storage.find_in_n2(target) {
|
|
for reporter in reporters {
|
|
if self.connections.contains_key(&reporter)
|
|
&& !candidates.iter().any(|(nid, _)| *nid == reporter)
|
|
&& candidates.len() < 3
|
|
{
|
|
candidates.push((reporter, 0));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 3: Intersect preferred_tree with N3 → TTL=1 candidates
|
|
if candidates.len() < 3 {
|
|
if let Ok(Some(route)) = storage.get_social_route(target) {
|
|
for tree_node in &route.preferred_tree {
|
|
if candidates.len() >= 3 {
|
|
break;
|
|
}
|
|
if let Ok(reporters) = storage.find_in_n3(tree_node) {
|
|
for reporter in reporters {
|
|
if self.connections.contains_key(&reporter)
|
|
&& !candidates.iter().any(|(nid, _)| *nid == reporter)
|
|
&& candidates.len() < 3
|
|
{
|
|
candidates.push((reporter, 1));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 4: Fallback — full N3 scan for target
|
|
if candidates.len() < 3 {
|
|
if let Ok(reporters) = storage.find_in_n3(target) {
|
|
for reporter in reporters {
|
|
if self.connections.contains_key(&reporter)
|
|
&& !candidates.iter().any(|(nid, _)| *nid == reporter)
|
|
&& candidates.len() < 3
|
|
{
|
|
candidates.push((reporter, 1));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
candidates
|
|
}
|
|
|
|
/// Send a RelayIntroduce request to a relay peer and wait for the result.
|
|
pub async fn send_relay_introduce(
|
|
&self,
|
|
relay_peer: &NodeId,
|
|
target: &NodeId,
|
|
ttl: u8,
|
|
) -> anyhow::Result<RelayIntroduceResultPayload> {
|
|
let pc = self.connections.get(relay_peer)
|
|
.ok_or_else(|| anyhow::anyhow!("relay peer not connected"))?;
|
|
|
|
let intro_id: IntroId = rand::random();
|
|
let mut our_addrs: Vec<String> = self.endpoint.addr().ip_addrs()
|
|
.filter(|s| crate::network::is_publicly_routable(s))
|
|
.map(|s| s.to_string())
|
|
.collect();
|
|
// Prepend UPnP external address if available
|
|
if let Some(ref ext) = self.upnp_external_addr {
|
|
let ext_str = ext.to_string();
|
|
if !our_addrs.contains(&ext_str) {
|
|
our_addrs.insert(0, ext_str);
|
|
}
|
|
}
|
|
|
|
let payload = RelayIntroducePayload {
|
|
intro_id,
|
|
target: *target,
|
|
requester: self.our_node_id,
|
|
requester_addresses: our_addrs,
|
|
ttl,
|
|
};
|
|
|
|
let (mut send, mut recv) = pc.connection.open_bi().await?;
|
|
write_typed_message(&mut send, MessageType::RelayIntroduce, &payload).await?;
|
|
send.finish()?;
|
|
|
|
let msg_type = read_message_type(&mut recv).await?;
|
|
if msg_type != MessageType::RelayIntroduceResult {
|
|
anyhow::bail!("expected RelayIntroduceResult, got {:?}", msg_type);
|
|
}
|
|
let result: RelayIntroduceResultPayload = read_payload(&mut recv, MAX_PAYLOAD).await?;
|
|
Ok(result)
|
|
}
|
|
|
|
/// Handle an incoming RelayIntroduce — either as relay (forward) or as target (respond).
|
|
/// `conn_mgr_arc` is passed so the spawned hole-punch task can register the
|
|
/// resulting connection without holding the lock during the 30 s punch window.
|
|
pub async fn handle_relay_introduce(
|
|
&mut self,
|
|
payload: RelayIntroducePayload,
|
|
mut send: iroh::endpoint::SendStream,
|
|
from_peer: NodeId,
|
|
conn_mgr_arc: Arc<Mutex<Self>>,
|
|
) -> anyhow::Result<()> {
|
|
let now = now_ms();
|
|
|
|
// Dedup check
|
|
if let Some(&seen_at) = self.seen_intros.get(&payload.intro_id) {
|
|
if now - seen_at < RELAY_INTRO_DEDUP_EXPIRY_MS {
|
|
let result = RelayIntroduceResultPayload {
|
|
intro_id: payload.intro_id,
|
|
accepted: false,
|
|
target_addresses: vec![],
|
|
relay_available: false,
|
|
reject_reason: Some("duplicate intro".to_string()),
|
|
};
|
|
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
|
|
send.finish()?;
|
|
return Ok(());
|
|
}
|
|
}
|
|
self.seen_intros.insert(payload.intro_id, now);
|
|
|
|
// Are WE the target?
|
|
if payload.target == self.our_node_id {
|
|
// Respond with our globally-routable addresses only (no Docker bridge / private IPs)
|
|
let mut our_addrs: Vec<String> = self.endpoint.addr().ip_addrs()
|
|
.filter(|s| crate::network::is_publicly_routable(s))
|
|
.map(|s| s.to_string())
|
|
.collect();
|
|
// Prepend UPnP external address if available
|
|
if let Some(ref ext) = self.upnp_external_addr {
|
|
let ext_str = ext.to_string();
|
|
if !our_addrs.contains(&ext_str) {
|
|
our_addrs.insert(0, ext_str);
|
|
}
|
|
}
|
|
|
|
let result = RelayIntroduceResultPayload {
|
|
intro_id: payload.intro_id,
|
|
accepted: true,
|
|
target_addresses: our_addrs,
|
|
relay_available: false,
|
|
reject_reason: None,
|
|
};
|
|
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
|
|
send.finish()?;
|
|
|
|
// Hole punch: filter to routable addresses only (skip Docker bridge IPs etc.)
|
|
let routable_requester_addrs: Vec<String> = payload.requester_addresses.iter()
|
|
.filter(|a| a.parse::<std::net::SocketAddr>().map_or(false, |s| crate::network::is_publicly_routable(&s)))
|
|
.cloned()
|
|
.collect();
|
|
info!(
|
|
requester = hex::encode(payload.requester),
|
|
addrs = ?routable_requester_addrs,
|
|
"Relay introduction: we are target, hole punching to requester"
|
|
);
|
|
self.log_activity(ActivityLevel::Info, ActivityCategory::Relay, format!("We are target, hole punching to {}", &hex::encode(payload.requester)[..8]), Some(payload.requester));
|
|
|
|
let endpoint = self.endpoint.clone();
|
|
let requester = payload.requester;
|
|
let requester_addrs = routable_requester_addrs;
|
|
let storage = Arc::clone(&self.storage);
|
|
let our_node_id = self.our_node_id;
|
|
let our_nat_type = self.nat_type;
|
|
let our_http_capable = self.http_capable;
|
|
let our_http_addr = self.http_addr.clone();
|
|
let our_nat_profile = self.our_nat_profile();
|
|
let peer_nat_profile = {
|
|
let s = self.storage.lock().await;
|
|
s.get_peer_nat_profile(&requester)
|
|
};
|
|
tokio::spawn(async move {
|
|
if let Some(conn) = hole_punch_with_scanning(&endpoint, &requester, &requester_addrs, our_nat_profile, peer_nat_profile).await {
|
|
// Register as session so the connection is actually used
|
|
let mut cm = conn_mgr_arc.lock().await;
|
|
if cm.is_connected(&requester) {
|
|
// Initiator already connected to us (their punch succeeded first)
|
|
return;
|
|
}
|
|
cm.add_session(requester, conn, SessionReachMethod::HolePunch, None);
|
|
cm.mark_reachable(&requester);
|
|
cm.log_activity(
|
|
ActivityLevel::Info,
|
|
ActivityCategory::Relay,
|
|
format!("Target-side hole punch succeeded to {}", &hex::encode(requester)[..8]),
|
|
Some(requester),
|
|
);
|
|
// Run initial exchange so both sides know each other
|
|
let storage_clone = Arc::clone(&storage);
|
|
if let Some(session) = cm.sessions.get(&requester) {
|
|
let session_conn = session.connection.clone();
|
|
drop(cm); // release lock before async work
|
|
match initial_exchange_connect(&storage_clone, &our_node_id, &session_conn, requester, None, our_nat_type, our_http_capable, our_http_addr.clone()).await {
|
|
Ok(ExchangeResult::Accepted) => {
|
|
tracing::info!(peer = hex::encode(requester), "Target-side: initial exchange after hole punch");
|
|
}
|
|
Ok(ExchangeResult::Refused { .. }) => {
|
|
tracing::debug!(peer = hex::encode(requester), "Target-side: mesh refused after hole punch (ok, initiator will drive)");
|
|
}
|
|
Err(e) => {
|
|
tracing::debug!(peer = hex::encode(requester), error = %e, "Target-side: initial exchange failed (ok, initiator will drive)");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
return Ok(());
|
|
}
|
|
|
|
// We are a RELAY — forward the introduction to the target
|
|
|
|
// Check if target is directly connected (our N1)
|
|
if let Some(target_pc) = self.connections.get(&payload.target) {
|
|
let target_conn = target_pc.connection.clone();
|
|
let target_observed_addr = target_pc.remote_addr;
|
|
let relay_available = self.can_accept_relay_pipe();
|
|
|
|
// Look up the requester's observed address from mesh or session
|
|
let requester_observed_addr = self.connections.get(&from_peer)
|
|
.and_then(|pc| pc.remote_addr)
|
|
.or_else(|| self.sessions.get(&from_peer).and_then(|s| s.remote_addr));
|
|
|
|
// Build forwarded payload with requester's real public address injected
|
|
let mut forwarded_payload = payload.clone();
|
|
if let Some(addr) = requester_observed_addr {
|
|
let addr_str = addr.to_string();
|
|
if !forwarded_payload.requester_addresses.contains(&addr_str) {
|
|
forwarded_payload.requester_addresses.insert(0, addr_str);
|
|
info!(
|
|
requester = hex::encode(payload.requester),
|
|
observed_addr = %addr,
|
|
"Relay injecting requester's observed public address"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Forward to target
|
|
let forward_result = async {
|
|
let (mut fwd_send, mut fwd_recv) = target_conn.open_bi().await?;
|
|
write_typed_message(&mut fwd_send, MessageType::RelayIntroduce, &forwarded_payload).await?;
|
|
fwd_send.finish()?;
|
|
|
|
let msg_type = read_message_type(&mut fwd_recv).await?;
|
|
if msg_type != MessageType::RelayIntroduceResult {
|
|
anyhow::bail!("expected RelayIntroduceResult from target, got {:?}", msg_type);
|
|
}
|
|
let mut result: RelayIntroduceResultPayload = read_payload(&mut fwd_recv, MAX_PAYLOAD).await?;
|
|
result.relay_available = relay_available;
|
|
|
|
// Inject target's observed public address into the result
|
|
if let Some(addr) = target_observed_addr {
|
|
let addr_str = addr.to_string();
|
|
if !result.target_addresses.contains(&addr_str) {
|
|
result.target_addresses.insert(0, addr_str);
|
|
}
|
|
}
|
|
|
|
anyhow::Ok(result)
|
|
}.await;
|
|
|
|
match forward_result {
|
|
Ok(result) => {
|
|
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
|
|
send.finish()?;
|
|
info!(
|
|
requester = hex::encode(payload.requester),
|
|
target = hex::encode(payload.target),
|
|
accepted = result.accepted,
|
|
target_observed = ?target_observed_addr,
|
|
requester_observed = ?requester_observed_addr,
|
|
"Relayed introduction (direct)"
|
|
);
|
|
self.log_activity(ActivityLevel::Info, ActivityCategory::Relay, format!("Forwarded introduction {} -> {}", &hex::encode(payload.requester)[..8], &hex::encode(payload.target)[..8]), None);
|
|
}
|
|
Err(e) => {
|
|
let result = RelayIntroduceResultPayload {
|
|
intro_id: payload.intro_id,
|
|
accepted: false,
|
|
target_addresses: vec![],
|
|
relay_available: false,
|
|
reject_reason: Some(format!("relay forward failed: {}", e)),
|
|
};
|
|
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
|
|
send.finish()?;
|
|
}
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
// Check if target is connected via session (e.g. refused mesh but still reachable)
|
|
if let Some(target_session) = self.sessions.get(&payload.target) {
|
|
let target_conn = target_session.connection.clone();
|
|
let target_observed_addr = target_session.remote_addr;
|
|
let relay_available = self.can_accept_relay_pipe();
|
|
|
|
// Look up requester's observed address from mesh or session
|
|
let requester_observed_addr = self.connections.get(&from_peer)
|
|
.and_then(|pc| pc.remote_addr)
|
|
.or_else(|| self.sessions.get(&from_peer).and_then(|s| s.remote_addr));
|
|
|
|
let mut forwarded_payload = payload.clone();
|
|
if let Some(addr) = requester_observed_addr {
|
|
let addr_str = addr.to_string();
|
|
if !forwarded_payload.requester_addresses.contains(&addr_str) {
|
|
forwarded_payload.requester_addresses.insert(0, addr_str);
|
|
info!(
|
|
requester = hex::encode(payload.requester),
|
|
observed_addr = %addr,
|
|
"Relay injecting requester's observed public address (via session)"
|
|
);
|
|
}
|
|
}
|
|
|
|
let forward_result = async {
|
|
let (mut fwd_send, mut fwd_recv) = target_conn.open_bi().await?;
|
|
write_typed_message(&mut fwd_send, MessageType::RelayIntroduce, &forwarded_payload).await?;
|
|
fwd_send.finish()?;
|
|
|
|
let msg_type = read_message_type(&mut fwd_recv).await?;
|
|
if msg_type != MessageType::RelayIntroduceResult {
|
|
anyhow::bail!("expected RelayIntroduceResult from target (session), got {:?}", msg_type);
|
|
}
|
|
let mut result: RelayIntroduceResultPayload = read_payload(&mut fwd_recv, MAX_PAYLOAD).await?;
|
|
result.relay_available = relay_available;
|
|
|
|
// Inject target's observed public address into the result
|
|
if let Some(addr) = target_observed_addr {
|
|
let addr_str = addr.to_string();
|
|
if !result.target_addresses.contains(&addr_str) {
|
|
result.target_addresses.insert(0, addr_str);
|
|
}
|
|
}
|
|
|
|
anyhow::Ok(result)
|
|
}.await;
|
|
|
|
match forward_result {
|
|
Ok(result) => {
|
|
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
|
|
send.finish()?;
|
|
info!(
|
|
requester = hex::encode(payload.requester),
|
|
target = hex::encode(payload.target),
|
|
accepted = result.accepted,
|
|
"Relayed introduction (via session)"
|
|
);
|
|
self.log_activity(ActivityLevel::Info, ActivityCategory::Relay, format!("Forwarded introduction {} -> {} (session)", &hex::encode(payload.requester)[..8], &hex::encode(payload.target)[..8]), None);
|
|
}
|
|
Err(e) => {
|
|
let result = RelayIntroduceResultPayload {
|
|
intro_id: payload.intro_id,
|
|
accepted: false,
|
|
target_addresses: vec![],
|
|
relay_available: false,
|
|
reject_reason: Some(format!("relay forward to session failed: {}", e)),
|
|
};
|
|
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
|
|
send.finish()?;
|
|
}
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
// Target not directly connected — try forwarding with ttl
|
|
if payload.ttl > 0 {
|
|
// Find an N2 reporter for the target and forward with ttl-1
|
|
let reporters = {
|
|
let storage = self.storage.lock().await;
|
|
storage.find_in_n2(&payload.target).unwrap_or_default()
|
|
};
|
|
|
|
for reporter in reporters {
|
|
if reporter == from_peer || reporter == self.our_node_id {
|
|
continue;
|
|
}
|
|
if let Some(reporter_pc) = self.connections.get(&reporter) {
|
|
let reporter_conn = reporter_pc.connection.clone();
|
|
let relay_available = self.can_accept_relay_pipe();
|
|
|
|
// Inject requester's observed address if we have it
|
|
let mut req_addrs = payload.requester_addresses.clone();
|
|
if let Some(addr) = self.connections.get(&from_peer).and_then(|pc| pc.remote_addr) {
|
|
let addr_str = addr.to_string();
|
|
if !req_addrs.contains(&addr_str) {
|
|
req_addrs.insert(0, addr_str);
|
|
}
|
|
}
|
|
|
|
let forwarded_payload = RelayIntroducePayload {
|
|
intro_id: payload.intro_id,
|
|
target: payload.target,
|
|
requester: payload.requester,
|
|
requester_addresses: req_addrs,
|
|
ttl: payload.ttl - 1,
|
|
};
|
|
|
|
let forward_result = async {
|
|
let (mut fwd_send, mut fwd_recv) = reporter_conn.open_bi().await?;
|
|
write_typed_message(&mut fwd_send, MessageType::RelayIntroduce, &forwarded_payload).await?;
|
|
fwd_send.finish()?;
|
|
|
|
let msg_type = read_message_type(&mut fwd_recv).await?;
|
|
if msg_type != MessageType::RelayIntroduceResult {
|
|
anyhow::bail!("expected RelayIntroduceResult from chain, got {:?}", msg_type);
|
|
}
|
|
let mut result: RelayIntroduceResultPayload = read_payload(&mut fwd_recv, MAX_PAYLOAD).await?;
|
|
result.relay_available = relay_available;
|
|
anyhow::Ok(result)
|
|
}.await;
|
|
|
|
match forward_result {
|
|
Ok(result) => {
|
|
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
|
|
send.finish()?;
|
|
info!(
|
|
requester = hex::encode(payload.requester),
|
|
target = hex::encode(payload.target),
|
|
via = hex::encode(reporter),
|
|
"Relayed introduction (chained)"
|
|
);
|
|
return Ok(());
|
|
}
|
|
Err(e) => {
|
|
debug!(reporter = hex::encode(reporter), error = %e, "Chain forward failed, trying next reporter");
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cannot relay — target not reachable through us
|
|
warn!(
|
|
requester = hex::encode(payload.requester),
|
|
target = hex::encode(payload.target),
|
|
ttl = payload.ttl,
|
|
our_connections = self.connections.len(),
|
|
"Relay introduction: target not reachable through us"
|
|
);
|
|
self.log_activity(ActivityLevel::Warn, ActivityCategory::Relay, format!("Target {} not reachable through relay", &hex::encode(payload.target)[..8]), Some(payload.target));
|
|
let result = RelayIntroduceResultPayload {
|
|
intro_id: payload.intro_id,
|
|
accepted: false,
|
|
target_addresses: vec![],
|
|
relay_available: false,
|
|
reject_reason: Some("target not reachable through relay".to_string()),
|
|
};
|
|
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
|
|
send.finish()?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Handle an incoming SessionRelay request — splice two bi-streams.
|
|
pub async fn handle_session_relay(
|
|
conn_mgr: Arc<Mutex<Self>>,
|
|
mut requester_recv: iroh::endpoint::RecvStream,
|
|
mut requester_send: iroh::endpoint::SendStream,
|
|
from_peer: NodeId,
|
|
) -> anyhow::Result<()> {
|
|
let payload: SessionRelayPayload = read_payload(&mut requester_recv, 4096).await?;
|
|
|
|
// Check capacity and find target connection
|
|
let (target_conn, active_pipes) = {
|
|
let cm = conn_mgr.lock().await;
|
|
if !cm.can_accept_relay_pipe() {
|
|
// Reject — at capacity
|
|
let result = RelayIntroduceResultPayload {
|
|
intro_id: payload.intro_id,
|
|
accepted: false,
|
|
target_addresses: vec![],
|
|
relay_available: false,
|
|
reject_reason: Some("relay at capacity".to_string()),
|
|
};
|
|
write_typed_message(&mut requester_send, MessageType::RelayIntroduceResult, &result).await?;
|
|
requester_send.finish()?;
|
|
return Ok(());
|
|
}
|
|
|
|
let target_conn = cm.connections.get(&payload.target)
|
|
.map(|pc| pc.connection.clone());
|
|
(target_conn, Arc::clone(cm.active_relay_pipes()))
|
|
};
|
|
|
|
let target_conn = match target_conn {
|
|
Some(c) => c,
|
|
None => {
|
|
let result = RelayIntroduceResultPayload {
|
|
intro_id: payload.intro_id,
|
|
accepted: false,
|
|
target_addresses: vec![],
|
|
relay_available: false,
|
|
reject_reason: Some("target not connected to relay".to_string()),
|
|
};
|
|
write_typed_message(&mut requester_send, MessageType::RelayIntroduceResult, &result).await?;
|
|
requester_send.finish()?;
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
// Open bi-stream to target
|
|
let (mut target_send, mut target_recv) = target_conn.open_bi().await?;
|
|
|
|
// Send SessionRelay message to target so they know what's happening
|
|
write_typed_message(&mut target_send, MessageType::SessionRelay, &payload).await?;
|
|
|
|
// Increment active pipe count
|
|
active_pipes.fetch_add(1, Ordering::Relaxed);
|
|
|
|
info!(
|
|
requester = hex::encode(from_peer),
|
|
target = hex::encode(payload.target),
|
|
"Starting relay pipe"
|
|
);
|
|
|
|
// Run the copy loop with byte limit and idle timeout
|
|
let result = Self::run_relay_pipe(
|
|
&mut requester_recv,
|
|
&mut requester_send,
|
|
&mut target_recv,
|
|
&mut target_send,
|
|
).await;
|
|
|
|
// Decrement active pipe count
|
|
active_pipes.fetch_sub(1, Ordering::Relaxed);
|
|
|
|
match result {
|
|
Ok(bytes) => {
|
|
info!(
|
|
requester = hex::encode(from_peer),
|
|
target = hex::encode(payload.target),
|
|
bytes,
|
|
"Relay pipe closed"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
debug!(
|
|
requester = hex::encode(from_peer),
|
|
target = hex::encode(payload.target),
|
|
error = %e,
|
|
"Relay pipe error"
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Run a bidirectional relay pipe with byte limit and idle timeout.
|
|
/// Returns total bytes transferred.
|
|
async fn run_relay_pipe(
|
|
requester_recv: &mut iroh::endpoint::RecvStream,
|
|
requester_send: &mut iroh::endpoint::SendStream,
|
|
target_recv: &mut iroh::endpoint::RecvStream,
|
|
target_send: &mut iroh::endpoint::SendStream,
|
|
) -> anyhow::Result<u64> {
|
|
let mut total_bytes: u64 = 0;
|
|
let mut buf_a = vec![0u8; 16384];
|
|
let mut buf_b = vec![0u8; 16384];
|
|
|
|
loop {
|
|
let idle_timeout = tokio::time::sleep(std::time::Duration::from_millis(RELAY_PIPE_IDLE_MS));
|
|
|
|
tokio::select! {
|
|
// requester → target
|
|
result = requester_recv.read(&mut buf_a) => {
|
|
match result {
|
|
Ok(Some(n)) => {
|
|
total_bytes += n as u64;
|
|
if total_bytes > RELAY_MAX_BYTES {
|
|
anyhow::bail!("relay byte limit exceeded");
|
|
}
|
|
target_send.write_all(&buf_a[..n]).await?;
|
|
}
|
|
Ok(None) => {
|
|
// requester finished sending
|
|
let _ = target_send.finish();
|
|
break;
|
|
}
|
|
Err(e) => {
|
|
anyhow::bail!("requester recv error: {}", e);
|
|
}
|
|
}
|
|
}
|
|
// target → requester
|
|
result = target_recv.read(&mut buf_b) => {
|
|
match result {
|
|
Ok(Some(n)) => {
|
|
total_bytes += n as u64;
|
|
if total_bytes > RELAY_MAX_BYTES {
|
|
anyhow::bail!("relay byte limit exceeded");
|
|
}
|
|
requester_send.write_all(&buf_b[..n]).await?;
|
|
}
|
|
Ok(None) => {
|
|
// target finished sending
|
|
let _ = requester_send.finish();
|
|
break;
|
|
}
|
|
Err(e) => {
|
|
anyhow::bail!("target recv error: {}", e);
|
|
}
|
|
}
|
|
}
|
|
_ = idle_timeout => {
|
|
anyhow::bail!("relay pipe idle timeout");
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(total_bytes)
|
|
}
|
|
|
|
/// Run the per-connection stream accept loop. Dispatches incoming streams by MessageType.
|
|
/// `last_activity` is updated on each successful stream accept for zombie detection.
|
|
pub async fn run_mesh_streams(
|
|
conn_mgr: Arc<Mutex<Self>>,
|
|
conn: iroh::endpoint::Connection,
|
|
remote_node_id: NodeId,
|
|
last_activity: Arc<AtomicU64>,
|
|
) {
|
|
let our_stable_id = conn.stable_id();
|
|
let keepalive_interval = std::time::Duration::from_secs(MESH_KEEPALIVE_INTERVAL_SECS);
|
|
loop {
|
|
tokio::select! {
|
|
uni_result = conn.accept_uni() => {
|
|
match uni_result {
|
|
Ok(mut recv) => {
|
|
last_activity.store(now_ms(), Ordering::Relaxed);
|
|
let cm = Arc::clone(&conn_mgr);
|
|
let remote = remote_node_id;
|
|
tokio::spawn(async move {
|
|
if let Err(e) = Self::handle_uni_stream(&cm, &mut recv, remote).await {
|
|
debug!(peer = hex::encode(remote), error = %e, "Uni-stream handler failed");
|
|
}
|
|
});
|
|
}
|
|
Err(e) => {
|
|
debug!(peer = hex::encode(remote_node_id), error = %e, "accept_uni failed, peer disconnected");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
bi_result = conn.accept_bi() => {
|
|
match bi_result {
|
|
Ok((send, recv)) => {
|
|
last_activity.store(now_ms(), Ordering::Relaxed);
|
|
let cm = Arc::clone(&conn_mgr);
|
|
let remote = remote_node_id;
|
|
tokio::spawn(async move {
|
|
if let Err(e) = Self::handle_bi_stream(&cm, recv, send, remote).await {
|
|
debug!(peer = hex::encode(remote), error = %e, "Bi-stream handler failed");
|
|
}
|
|
});
|
|
}
|
|
Err(e) => {
|
|
debug!(peer = hex::encode(remote_node_id), error = %e, "accept_bi failed, peer disconnected");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
_ = tokio::time::sleep(keepalive_interval) => {
|
|
// Send lightweight keepalive ping — keeps NAT mapping alive
|
|
// and prevents zombie detection on the remote side
|
|
if let Ok(mut send) = conn.open_uni().await {
|
|
let _ = send.write_all(&[MessageType::MeshKeepalive.as_byte()]).await;
|
|
let _ = send.finish();
|
|
// Update our own last_activity so we don't zombie-detect this
|
|
// connection if the remote stops sending keepalives to us
|
|
last_activity.store(now_ms(), Ordering::Relaxed);
|
|
} else {
|
|
debug!(peer = hex::encode(remote_node_id), "Keepalive send failed, peer disconnected");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Connection ended — only clean up if this is still the active connection
|
|
// (a reconnect may have already replaced our entry with a newer connection)
|
|
let mut cm = conn_mgr.lock().await;
|
|
let is_current = cm.connections.get(&remote_node_id)
|
|
.map_or(false, |pc| pc.connection.stable_id() == our_stable_id);
|
|
if is_current {
|
|
cm.disconnect_peer(&remote_node_id).await;
|
|
} else {
|
|
debug!(peer = hex::encode(remote_node_id), "Skipping disconnect — connection was replaced by reconnect");
|
|
}
|
|
}
|
|
|
|
// ---- Anchor referral methods ----
|
|
|
|
/// Anchor-side: register a peer in the referral list.
|
|
/// Uses the observed remote address from the peers table (the NAT-mapped public IP
|
|
/// seen by the anchor from the QUIC connection) rather than self-reported addresses,
|
|
/// which are often private/LAN IPs for NAT'd peers.
|
|
pub async fn handle_anchor_register(&mut self, payload: AnchorRegisterPayload) {
|
|
if !self.is_anchor.load(Ordering::Relaxed) {
|
|
return;
|
|
}
|
|
if !self.connections.contains_key(&payload.node_id) && !self.sessions.contains_key(&payload.node_id) {
|
|
debug!(peer = hex::encode(payload.node_id), "AnchorRegister from non-connected/session peer, ignoring");
|
|
return;
|
|
}
|
|
|
|
// Prefer observed remote address (NAT-mapped public IP) over self-reported
|
|
let addresses = {
|
|
let storage = self.storage.lock().await;
|
|
let observed = storage.get_peer_record(&payload.node_id)
|
|
.ok().flatten()
|
|
.map(|r| r.addresses).unwrap_or_default();
|
|
if observed.is_empty() {
|
|
// Fall back to self-reported addresses
|
|
payload.addresses
|
|
} else {
|
|
// Use observed addresses (public IP as seen by anchor)
|
|
observed.iter().map(|a| a.to_string()).collect()
|
|
}
|
|
};
|
|
|
|
let now = now_ms();
|
|
self.referral_list.insert(payload.node_id, ReferralEntry {
|
|
node_id: payload.node_id,
|
|
addresses,
|
|
registered_at: now,
|
|
use_count: 0,
|
|
disconnected_at: None,
|
|
});
|
|
debug!(
|
|
peer = hex::encode(payload.node_id),
|
|
list_size = self.referral_list.len(),
|
|
"Anchor: registered peer in referral list"
|
|
);
|
|
}
|
|
|
|
/// Anchor-side: pick referrals from the list, applying tiered usage and self-pruning.
|
|
pub fn pick_referrals(&mut self, exclude: &NodeId, count: usize) -> Vec<AnchorReferral> {
|
|
let now = now_ms();
|
|
|
|
// Prune: remove entries where disconnected_at is >2 min ago
|
|
self.referral_list.retain(|_, entry| {
|
|
match entry.disconnected_at {
|
|
Some(disc_at) if now.saturating_sub(disc_at) > REFERRAL_DISCONNECT_GRACE_MS => false,
|
|
_ => true,
|
|
}
|
|
});
|
|
|
|
// Tiered usage policy
|
|
let list_len = self.referral_list.len();
|
|
let max_uses: u32 = if list_len < REFERRAL_LIST_CAP {
|
|
3
|
|
} else if list_len == REFERRAL_LIST_CAP {
|
|
2
|
|
} else {
|
|
1
|
|
};
|
|
|
|
// Filter eligible entries
|
|
let mut eligible: Vec<&NodeId> = self.referral_list.iter()
|
|
.filter(|(nid, entry)| {
|
|
*nid != exclude
|
|
&& entry.use_count < max_uses
|
|
&& entry.disconnected_at.is_none()
|
|
})
|
|
.map(|(nid, _)| nid)
|
|
.collect();
|
|
|
|
// Sort: lowest use_count first, then most recent registration
|
|
eligible.sort_by(|a, b| {
|
|
let ea = &self.referral_list[*a];
|
|
let eb = &self.referral_list[*b];
|
|
ea.use_count.cmp(&eb.use_count)
|
|
.then(eb.registered_at.cmp(&ea.registered_at))
|
|
});
|
|
|
|
eligible.truncate(count);
|
|
let picked_ids: Vec<NodeId> = eligible.into_iter().copied().collect();
|
|
|
|
let mut result = Vec::with_capacity(picked_ids.len());
|
|
for nid in &picked_ids {
|
|
if let Some(entry) = self.referral_list.get_mut(nid) {
|
|
entry.use_count += 1;
|
|
result.push(AnchorReferral {
|
|
node_id: entry.node_id,
|
|
addresses: entry.addresses.clone(),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Self-prune: remove entries that reached max_uses
|
|
self.referral_list.retain(|_, entry| entry.use_count < max_uses);
|
|
|
|
result
|
|
}
|
|
|
|
/// Mark a peer as disconnected in the referral list.
|
|
pub fn mark_referral_disconnected(&mut self, node_id: &NodeId) {
|
|
if let Some(entry) = self.referral_list.get_mut(node_id) {
|
|
entry.disconnected_at = Some(now_ms());
|
|
}
|
|
}
|
|
|
|
/// Mark a peer as reconnected in the referral list.
|
|
pub fn mark_referral_reconnected(&mut self, node_id: &NodeId) {
|
|
if let Some(entry) = self.referral_list.get_mut(node_id) {
|
|
entry.disconnected_at = None;
|
|
}
|
|
}
|
|
|
|
/// Anchor-side: handle a referral request (bi-stream).
|
|
/// Supplements from mesh peers when the explicit referral list is sparse.
|
|
pub async fn handle_anchor_referral_request(
|
|
&mut self,
|
|
payload: AnchorReferralRequestPayload,
|
|
mut send: iroh::endpoint::SendStream,
|
|
) -> anyhow::Result<()> {
|
|
// Also register the requester (they provide addresses, they're connected)
|
|
self.handle_anchor_register(AnchorRegisterPayload {
|
|
node_id: payload.requester,
|
|
addresses: payload.requester_addresses,
|
|
}).await;
|
|
|
|
let mut referrals = self.pick_referrals(&payload.requester, 3);
|
|
|
|
// Auto-refer from mesh peers when referral list is sparse
|
|
if referrals.len() < 3 {
|
|
let referred_ids: std::collections::HashSet<NodeId> = referrals.iter().map(|r| r.node_id).collect();
|
|
let mut mesh_candidates: Vec<_> = self.connections.iter()
|
|
.filter(|(nid, _)| **nid != payload.requester && !referred_ids.contains(*nid) && **nid != self.our_node_id)
|
|
.filter_map(|(nid, pc)| pc.remote_addr.map(|a| (*nid, a.to_string())))
|
|
.collect();
|
|
// Shuffle for variety
|
|
use rand::seq::SliceRandom;
|
|
mesh_candidates.shuffle(&mut rand::rng());
|
|
for (nid, addr) in mesh_candidates.into_iter().take(3 - referrals.len()) {
|
|
referrals.push(AnchorReferral { node_id: nid, addresses: vec![addr] });
|
|
}
|
|
}
|
|
|
|
info!(
|
|
requester = hex::encode(payload.requester),
|
|
referral_count = referrals.len(),
|
|
"Anchor: serving referrals"
|
|
);
|
|
|
|
let response = AnchorReferralResponsePayload { referrals };
|
|
write_typed_message(&mut send, MessageType::AnchorReferralResponse, &response).await?;
|
|
send.finish()?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Client-side: request referrals from an anchor peer (mesh or session).
|
|
pub async fn request_anchor_referrals(
|
|
&mut self,
|
|
anchor_peer: &NodeId,
|
|
) -> anyhow::Result<Vec<AnchorReferral>> {
|
|
let conn = if let Some(pc) = self.connections.get(anchor_peer) {
|
|
pc.connection.clone()
|
|
} else if let Some(session) = self.sessions.get(anchor_peer) {
|
|
session.connection.clone()
|
|
} else {
|
|
anyhow::bail!("anchor peer not connected (mesh or session)");
|
|
};
|
|
|
|
let our_addrs: Vec<String> = self.endpoint.addr().ip_addrs()
|
|
.map(|s| s.to_string())
|
|
.collect();
|
|
|
|
let request = AnchorReferralRequestPayload {
|
|
requester: self.our_node_id,
|
|
requester_addresses: our_addrs,
|
|
};
|
|
|
|
let (mut send, mut recv) = conn.open_bi().await?;
|
|
write_typed_message(&mut send, MessageType::AnchorReferralRequest, &request).await?;
|
|
send.finish()?;
|
|
|
|
let msg_type = read_message_type(&mut recv).await?;
|
|
if msg_type != MessageType::AnchorReferralResponse {
|
|
anyhow::bail!("expected AnchorReferralResponse, got {:?}", msg_type);
|
|
}
|
|
let response: AnchorReferralResponsePayload = read_payload(&mut recv, 4096).await?;
|
|
|
|
// Touch session last_active to prevent idle reaping
|
|
if let Some(session) = self.sessions.get_mut(anchor_peer) {
|
|
session.last_active_at = now_ms();
|
|
}
|
|
|
|
Ok(response.referrals)
|
|
}
|
|
|
|
/// Client-side: register our address with an anchor peer (mesh or session).
|
|
pub async fn send_anchor_register(&mut self, anchor_peer: &NodeId) -> anyhow::Result<()> {
|
|
let conn = if let Some(pc) = self.connections.get(anchor_peer) {
|
|
pc.connection.clone()
|
|
} else if let Some(session) = self.sessions.get(anchor_peer) {
|
|
session.connection.clone()
|
|
} else {
|
|
anyhow::bail!("anchor peer not connected (mesh or session)");
|
|
};
|
|
|
|
let mut our_addrs: Vec<String> = self.endpoint.addr().ip_addrs()
|
|
.map(|s| s.to_string())
|
|
.collect();
|
|
// Prepend UPnP external address (most useful for remote peers)
|
|
if let Some(ref ext) = self.upnp_external_addr {
|
|
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.build_anchor_advertised_addr() {
|
|
if !our_addrs.contains(&anchor_addr) {
|
|
our_addrs.insert(0, anchor_addr);
|
|
}
|
|
}
|
|
|
|
let payload = AnchorRegisterPayload {
|
|
node_id: self.our_node_id,
|
|
addresses: our_addrs,
|
|
};
|
|
|
|
let mut send = conn.open_uni().await?;
|
|
write_typed_message(&mut send, MessageType::AnchorRegister, &payload).await?;
|
|
send.finish()?;
|
|
|
|
// Touch session last_active to prevent idle reaping
|
|
if let Some(session) = self.sessions.get_mut(anchor_peer) {
|
|
session.last_active_at = now_ms();
|
|
}
|
|
|
|
debug!(anchor = hex::encode(anchor_peer), "Registered with anchor");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Handle an incoming NAT filter probe request (anchor side).
|
|
/// Static method — does NOT hold conn_mgr lock during the 2s probe.
|
|
pub async fn handle_nat_filter_probe_static(
|
|
payload: crate::protocol::NatFilterProbePayload,
|
|
observed_addr: Option<std::net::SocketAddr>,
|
|
mut send: iroh::endpoint::SendStream,
|
|
) -> anyhow::Result<()> {
|
|
let observed = match observed_addr {
|
|
Some(addr) => addr,
|
|
None => {
|
|
info!(peer = hex::encode(payload.node_id), "NAT filter probe: no observed address, returning unreachable");
|
|
let result = crate::protocol::NatFilterProbeResultPayload { reachable: false };
|
|
write_typed_message(&mut send, MessageType::NatFilterProbeResult, &result).await?;
|
|
send.finish()?;
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
info!(
|
|
peer = hex::encode(payload.node_id),
|
|
observed = %observed,
|
|
"NAT filter probe: testing reachability from different port"
|
|
);
|
|
|
|
// Create a temporary endpoint on a random port and try to connect
|
|
let reachable = match Self::probe_from_different_port(&payload.node_id, observed).await {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
debug!(error = %e, "NAT filter probe: test failed");
|
|
false
|
|
}
|
|
};
|
|
|
|
info!(
|
|
peer = hex::encode(payload.node_id),
|
|
reachable,
|
|
"NAT filter probe result — sending response"
|
|
);
|
|
|
|
let result = crate::protocol::NatFilterProbeResultPayload { reachable };
|
|
write_typed_message(&mut send, MessageType::NatFilterProbeResult, &result).await?;
|
|
send.finish()?;
|
|
info!(peer = hex::encode(payload.node_id), "NAT filter probe response sent");
|
|
Ok(())
|
|
}
|
|
|
|
/// Try to connect to a peer from a fresh temporary endpoint (different source port).
|
|
/// Returns true if we could reach them (address-restricted or better),
|
|
/// false if we couldn't (port-restricted).
|
|
async fn probe_from_different_port(
|
|
target: &NodeId,
|
|
observed_addr: std::net::SocketAddr,
|
|
) -> anyhow::Result<bool> {
|
|
// Build a temporary endpoint with a random port
|
|
let secret_key = iroh::SecretKey::generate(&mut rand::rng());
|
|
let temp_ep = iroh::Endpoint::builder()
|
|
.alpns(vec![ALPN_V2.to_vec()])
|
|
.secret_key(secret_key)
|
|
.bind()
|
|
.await?;
|
|
|
|
let eid = iroh::EndpointId::from_bytes(target)?;
|
|
let addr = iroh::EndpointAddr::from(eid).with_ip_addr(observed_addr);
|
|
|
|
// Try connecting with a short timeout (2s)
|
|
let result = tokio::time::timeout(
|
|
std::time::Duration::from_secs(2),
|
|
temp_ep.connect(addr, ALPN_V2),
|
|
).await;
|
|
|
|
// Clean up the temporary endpoint
|
|
temp_ep.close().await;
|
|
|
|
match result {
|
|
Ok(Ok(_conn)) => Ok(true), // reached them — Open filtering
|
|
Ok(Err(_)) => Ok(false), // connection error — likely PortRestricted
|
|
Err(_) => Ok(false), // timeout — PortRestricted
|
|
}
|
|
}
|
|
|
|
pub async fn handle_uni_stream(
|
|
conn_mgr: &Arc<Mutex<Self>>,
|
|
recv: &mut iroh::endpoint::RecvStream,
|
|
remote_node_id: NodeId,
|
|
) -> anyhow::Result<()> {
|
|
let msg_type = read_message_type(recv).await?;
|
|
|
|
match msg_type {
|
|
MessageType::NodeListUpdate => {
|
|
let diff: NodeListUpdatePayload = read_payload(recv, MAX_PAYLOAD).await?;
|
|
let has_n1_additions = !diff.n1_added.is_empty();
|
|
let cm = conn_mgr.lock().await;
|
|
let count = cm.process_routing_diff(&remote_node_id, diff).await?;
|
|
if count > 0 {
|
|
debug!(peer = hex::encode(remote_node_id), count, "Applied node list update");
|
|
}
|
|
if has_n1_additions {
|
|
cm.notify_growth();
|
|
}
|
|
}
|
|
MessageType::ProfileUpdate => {
|
|
let payload: ProfileUpdatePayload = read_payload(recv, MAX_PAYLOAD).await?;
|
|
let cm = conn_mgr.lock().await;
|
|
let storage = cm.storage.lock().await;
|
|
for profile in payload.profiles {
|
|
let _ = storage.store_profile(&profile);
|
|
}
|
|
}
|
|
MessageType::DeleteRecord => {
|
|
let payload: crate::protocol::DeleteRecordPayload =
|
|
read_payload(recv, MAX_PAYLOAD).await?;
|
|
let cm = conn_mgr.lock().await;
|
|
|
|
// Collect blob CIDs + CDN peers before async work
|
|
let mut blob_cleanup: Vec<([u8; 32], Vec<(NodeId, Vec<String>)>, Option<(NodeId, Vec<String>)>)> = Vec::new();
|
|
{
|
|
let storage = cm.storage.lock().await;
|
|
for dr in &payload.records {
|
|
if crypto::verify_delete_signature(&dr.author, &dr.post_id, &dr.signature) {
|
|
// Collect blobs for CDN cleanup before deleting
|
|
let blob_cids = storage.get_blobs_for_post(&dr.post_id).unwrap_or_default();
|
|
for cid in blob_cids {
|
|
let downstream = storage.get_blob_downstream(&cid).unwrap_or_default();
|
|
let upstream = storage.get_blob_upstream(&cid).ok().flatten();
|
|
blob_cleanup.push((cid, downstream, upstream));
|
|
}
|
|
let _ = storage.store_delete(dr);
|
|
let _ = storage.apply_delete(dr);
|
|
// Delete blob metadata + CDN metadata
|
|
let deleted_cids = storage.delete_blobs_for_post(&dr.post_id).unwrap_or_default();
|
|
for cid in &deleted_cids {
|
|
let _ = storage.cleanup_cdn_for_blob(cid);
|
|
let _ = cm.blob_store.delete(cid);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Send CDN delete notices (async, best-effort)
|
|
for (cid, downstream, upstream) in &blob_cleanup {
|
|
// Notify downstream (with our upstream info for tree healing)
|
|
let upstream_info = upstream.as_ref().map(|(nid, addrs)| {
|
|
PeerWithAddress {
|
|
n: hex::encode(nid),
|
|
a: addrs.clone(),
|
|
}
|
|
});
|
|
let ds_payload = crate::protocol::BlobDeleteNoticePayload {
|
|
cid: *cid,
|
|
upstream_node: upstream_info,
|
|
};
|
|
for (ds_nid, _) in downstream {
|
|
if let Some(pc) = cm.connections_ref().get(ds_nid) {
|
|
if let Ok(mut send) = pc.connection.open_uni().await {
|
|
let _ = write_typed_message(&mut send, MessageType::BlobDeleteNotice, &ds_payload).await;
|
|
let _ = send.finish();
|
|
}
|
|
}
|
|
}
|
|
// Notify upstream (no upstream info — just "remove me")
|
|
if let Some((up_nid, _)) = upstream {
|
|
let up_payload = crate::protocol::BlobDeleteNoticePayload {
|
|
cid: *cid,
|
|
upstream_node: None,
|
|
};
|
|
if let Some(pc) = cm.connections_ref().get(up_nid) {
|
|
if let Ok(mut send) = pc.connection.open_uni().await {
|
|
let _ = write_typed_message(&mut send, MessageType::BlobDeleteNotice, &up_payload).await;
|
|
let _ = send.finish();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
MessageType::VisibilityUpdate => {
|
|
let payload: crate::protocol::VisibilityUpdatePayload =
|
|
read_payload(recv, MAX_PAYLOAD).await?;
|
|
let cm = conn_mgr.lock().await;
|
|
let storage = cm.storage.lock().await;
|
|
for vu in payload.updates {
|
|
if let Some(post) = storage.get_post(&vu.post_id)? {
|
|
if post.author == vu.author {
|
|
let _ = storage.update_post_visibility(&vu.post_id, &vu.visibility);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
MessageType::PostNotification => {
|
|
let notification: PostNotificationPayload =
|
|
read_payload(recv, MAX_PAYLOAD).await?;
|
|
info!(
|
|
peer = hex::encode(remote_node_id),
|
|
post_id = hex::encode(notification.post_id),
|
|
author = hex::encode(notification.author),
|
|
"Received post notification"
|
|
);
|
|
let cm = conn_mgr.lock().await;
|
|
match cm.handle_post_notification(&remote_node_id, notification, None).await {
|
|
Ok(true) => {
|
|
info!(peer = hex::encode(remote_node_id), "Pulled post from notification");
|
|
}
|
|
Ok(false) => {
|
|
info!(peer = hex::encode(remote_node_id), "Post notification ignored (not following or already have)");
|
|
}
|
|
Err(e) => {
|
|
warn!(peer = hex::encode(remote_node_id), error = %e, "Post notification pull failed");
|
|
}
|
|
}
|
|
}
|
|
MessageType::PostPush => {
|
|
let push: PostPushPayload = read_payload(recv, MAX_PAYLOAD).await?;
|
|
let cm = conn_mgr.lock().await;
|
|
let storage = cm.storage.lock().await;
|
|
if !storage.is_deleted(&push.post.id)?
|
|
&& storage.get_post(&push.post.id)?.is_none()
|
|
&& crate::content::verify_post_id(&push.post.id, &push.post.post)
|
|
{
|
|
let _ = storage.store_post_with_visibility(
|
|
&push.post.id,
|
|
&push.post.post,
|
|
&push.post.visibility,
|
|
);
|
|
let _ = storage.set_post_upstream(&push.post.id, &remote_node_id);
|
|
info!(
|
|
peer = hex::encode(remote_node_id),
|
|
post_id = hex::encode(push.post.id),
|
|
"Received direct post push"
|
|
);
|
|
}
|
|
}
|
|
MessageType::AudienceRequest => {
|
|
let req: AudienceRequestPayload = read_payload(recv, MAX_PAYLOAD).await?;
|
|
info!(
|
|
peer = hex::encode(remote_node_id),
|
|
requester = hex::encode(req.requester),
|
|
"Received audience request"
|
|
);
|
|
let cm = conn_mgr.lock().await;
|
|
let storage = cm.storage.lock().await;
|
|
// Store as inbound pending request
|
|
let _ = storage.store_audience(
|
|
&req.requester,
|
|
crate::types::AudienceDirection::Inbound,
|
|
crate::types::AudienceStatus::Pending,
|
|
);
|
|
}
|
|
MessageType::AudienceResponse => {
|
|
let resp: AudienceResponsePayload = read_payload(recv, MAX_PAYLOAD).await?;
|
|
let status = if resp.approved { "approved" } else { "denied" };
|
|
info!(
|
|
peer = hex::encode(remote_node_id),
|
|
responder = hex::encode(resp.responder),
|
|
status,
|
|
"Received audience response"
|
|
);
|
|
let cm = conn_mgr.lock().await;
|
|
let storage = cm.storage.lock().await;
|
|
let new_status = if resp.approved {
|
|
crate::types::AudienceStatus::Approved
|
|
} else {
|
|
crate::types::AudienceStatus::Denied
|
|
};
|
|
let _ = storage.store_audience(
|
|
&resp.responder,
|
|
crate::types::AudienceDirection::Outbound,
|
|
new_status,
|
|
);
|
|
}
|
|
MessageType::SocialAddressUpdate => {
|
|
let payload: SocialAddressUpdatePayload = read_payload(recv, MAX_PAYLOAD).await?;
|
|
let cm = conn_mgr.lock().await;
|
|
let storage = cm.storage.lock().await;
|
|
if storage.has_social_route(&payload.node_id).unwrap_or(false) {
|
|
let addrs: Vec<std::net::SocketAddr> = payload.addresses.iter()
|
|
.filter_map(|a| a.parse().ok()).collect();
|
|
let _ = storage.touch_social_route_connect(
|
|
&payload.node_id, &addrs, ReachMethod::Referral,
|
|
);
|
|
let _ = storage.update_social_route_peer_addrs(
|
|
&payload.node_id, &payload.peer_addresses,
|
|
);
|
|
}
|
|
debug!(
|
|
peer = hex::encode(remote_node_id),
|
|
target = hex::encode(payload.node_id),
|
|
"Received social address update"
|
|
);
|
|
}
|
|
MessageType::ManifestPush => {
|
|
let payload: crate::protocol::ManifestPushPayload = read_payload(recv, MAX_PAYLOAD).await?;
|
|
let cm = conn_mgr.lock().await;
|
|
let storage = cm.storage.lock().await;
|
|
let mut stored_entries: Vec<crate::protocol::ManifestPushEntry> = Vec::new();
|
|
for entry in &payload.manifests {
|
|
if !crate::crypto::verify_manifest_signature(&entry.manifest.author_manifest) {
|
|
continue;
|
|
}
|
|
// Only store if newer than what we have
|
|
let dominated = storage.get_cdn_manifest(&entry.cid).ok().flatten()
|
|
.and_then(|json| serde_json::from_str::<crate::types::CdnManifest>(&json).ok())
|
|
.map(|existing| existing.author_manifest.updated_at >= entry.manifest.author_manifest.updated_at)
|
|
.unwrap_or(false);
|
|
if dominated {
|
|
continue;
|
|
}
|
|
let manifest_json = match serde_json::to_string(&entry.manifest) {
|
|
Ok(j) => j,
|
|
Err(_) => continue,
|
|
};
|
|
let _ = storage.store_cdn_manifest(
|
|
&entry.cid,
|
|
&manifest_json,
|
|
&entry.manifest.author_manifest.author,
|
|
entry.manifest.author_manifest.updated_at,
|
|
);
|
|
stored_entries.push(entry.clone());
|
|
}
|
|
// Gather downstream peers for relay before dropping locks
|
|
let mut relay_targets: Vec<(NodeId, crate::protocol::ManifestPushPayload)> = Vec::new();
|
|
for entry in &stored_entries {
|
|
let downstream = storage.get_blob_downstream(&entry.cid).unwrap_or_default();
|
|
for (ds_nid, _) in downstream {
|
|
if ds_nid == remote_node_id {
|
|
continue;
|
|
}
|
|
relay_targets.push((ds_nid, crate::protocol::ManifestPushPayload {
|
|
manifests: vec![entry.clone()],
|
|
}));
|
|
}
|
|
}
|
|
let stored = stored_entries.len();
|
|
drop(storage);
|
|
// Relay to downstream (best-effort via mesh connections)
|
|
for (ds_nid, relay_payload) in &relay_targets {
|
|
if let Some(pc) = cm.connections_ref().get(ds_nid) {
|
|
if let Ok(mut send) = pc.connection.open_uni().await {
|
|
let _ = write_typed_message(&mut send, MessageType::ManifestPush, relay_payload).await;
|
|
let _ = send.finish();
|
|
}
|
|
}
|
|
}
|
|
drop(cm);
|
|
debug!(peer = hex::encode(remote_node_id), stored, relayed = relay_targets.len(), "Received manifest push");
|
|
}
|
|
MessageType::SocialDisconnectNotice => {
|
|
let payload: SocialDisconnectNoticePayload = read_payload(recv, MAX_PAYLOAD).await?;
|
|
let cm = conn_mgr.lock().await;
|
|
let storage = cm.storage.lock().await;
|
|
if storage.has_social_route(&payload.node_id).unwrap_or(false) {
|
|
let _ = storage.set_social_route_status(&payload.node_id, SocialStatus::Disconnected);
|
|
}
|
|
debug!(
|
|
peer = hex::encode(remote_node_id),
|
|
target = hex::encode(payload.node_id),
|
|
"Received social disconnect notice"
|
|
);
|
|
}
|
|
MessageType::BlobDeleteNotice => {
|
|
let payload: crate::protocol::BlobDeleteNoticePayload =
|
|
read_payload(recv, MAX_PAYLOAD).await?;
|
|
let cm = conn_mgr.lock().await;
|
|
let storage = cm.storage.lock().await;
|
|
let cid = payload.cid;
|
|
|
|
// Check if sender was our upstream for this blob
|
|
let was_upstream = storage.get_blob_upstream(&cid).ok().flatten()
|
|
.map(|(nid, _)| nid == remote_node_id)
|
|
.unwrap_or(false);
|
|
|
|
if was_upstream {
|
|
// Sender was our upstream — clear it
|
|
let _ = storage.remove_blob_upstream(&cid);
|
|
|
|
// If they provided their upstream, store it as our new upstream
|
|
if let Some(ref new_up) = payload.upstream_node {
|
|
if let Ok(nid_bytes) = hex::decode(&new_up.n) {
|
|
if let Ok(nid) = <[u8; 32]>::try_from(nid_bytes.as_slice()) {
|
|
let _ = storage.store_blob_upstream(&cid, &nid, &new_up.a);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Sender was our downstream — remove them
|
|
let _ = storage.remove_blob_downstream(&cid, &remote_node_id);
|
|
}
|
|
|
|
info!(
|
|
peer = hex::encode(remote_node_id),
|
|
cid = hex::encode(cid),
|
|
was_upstream,
|
|
"Received blob delete notice"
|
|
);
|
|
}
|
|
MessageType::GroupKeyDistribute => {
|
|
let payload: GroupKeyDistributePayload = read_payload(recv, MAX_PAYLOAD).await?;
|
|
let cm = conn_mgr.lock().await;
|
|
|
|
// Verify the sender is the admin
|
|
if payload.admin != remote_node_id {
|
|
warn!(peer = hex::encode(remote_node_id), "GroupKeyDistribute from non-admin, ignoring");
|
|
} else {
|
|
let storage = cm.storage.lock().await;
|
|
let record = crate::types::GroupKeyRecord {
|
|
group_id: payload.group_id,
|
|
circle_name: payload.circle_name.clone(),
|
|
epoch: payload.epoch,
|
|
group_public_key: payload.group_public_key,
|
|
admin: payload.admin,
|
|
created_at: now_ms(),
|
|
};
|
|
let _ = storage.create_group_key(&record, None);
|
|
|
|
// Find our wrapped key and unwrap the group seed
|
|
for mk in &payload.member_keys {
|
|
let _ = storage.store_group_member_key(&payload.group_id, mk);
|
|
if mk.member == cm.our_node_id {
|
|
match crypto::unwrap_group_key(
|
|
&cm.secret_seed,
|
|
&payload.admin,
|
|
&mk.wrapped_group_key,
|
|
) {
|
|
Ok(seed) => {
|
|
let _ = storage.store_group_seed(&payload.group_id, payload.epoch, &seed);
|
|
info!(
|
|
circle = %payload.circle_name,
|
|
epoch = payload.epoch,
|
|
"Received and unwrapped group key"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
warn!(error = %e, "Failed to unwrap group key");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
MessageType::CircleProfileUpdate => {
|
|
let payload: CircleProfileUpdatePayload = read_payload(recv, MAX_PAYLOAD).await?;
|
|
let cm = conn_mgr.lock().await;
|
|
|
|
// Try to decrypt if we have the group seed
|
|
let storage = cm.storage.lock().await;
|
|
let decrypted = storage
|
|
.get_group_seed(&payload.group_id, payload.epoch)
|
|
.ok()
|
|
.flatten()
|
|
.and_then(|seed| {
|
|
let gk = storage.get_group_key(&payload.group_id).ok()??;
|
|
let json = crypto::decrypt_group_post(
|
|
&payload.encrypted_payload,
|
|
&seed,
|
|
&gk.group_public_key,
|
|
&payload.wrapped_cek,
|
|
)
|
|
.ok()?;
|
|
// Tombstone: empty string means deleted
|
|
if json.is_empty() {
|
|
let _ = storage.delete_circle_profile(&payload.author, &payload.circle_name);
|
|
return None;
|
|
}
|
|
serde_json::from_str::<crate::types::CircleProfile>(&json).ok()
|
|
});
|
|
|
|
if let Some(cp) = decrypted {
|
|
// Store decrypted + encrypted form
|
|
let _ = storage.store_remote_circle_profile(
|
|
&payload.author,
|
|
&payload.circle_name,
|
|
&cp,
|
|
&payload.encrypted_payload,
|
|
&payload.wrapped_cek,
|
|
&payload.group_id,
|
|
payload.epoch,
|
|
);
|
|
debug!(
|
|
peer = hex::encode(remote_node_id),
|
|
author = hex::encode(payload.author),
|
|
circle = %payload.circle_name,
|
|
"Decrypted and stored circle profile"
|
|
);
|
|
} else {
|
|
// Can't decrypt — store encrypted form only for relay
|
|
let _ = storage.store_encrypted_circle_profile(
|
|
&payload.author,
|
|
&payload.circle_name,
|
|
&payload.encrypted_payload,
|
|
&payload.wrapped_cek,
|
|
&payload.group_id,
|
|
payload.epoch,
|
|
payload.updated_at,
|
|
);
|
|
debug!(
|
|
peer = hex::encode(remote_node_id),
|
|
author = hex::encode(payload.author),
|
|
circle = %payload.circle_name,
|
|
"Stored encrypted circle profile (no group key)"
|
|
);
|
|
}
|
|
|
|
// Relay to other connected peers (gossip)
|
|
for (peer_id, mc) in cm.connections_ref() {
|
|
if *peer_id == remote_node_id {
|
|
continue;
|
|
}
|
|
if let Ok(mut send) = mc.connection.open_uni().await {
|
|
let _ = write_typed_message(
|
|
&mut send,
|
|
MessageType::CircleProfileUpdate,
|
|
&payload,
|
|
)
|
|
.await;
|
|
let _ = send.finish();
|
|
}
|
|
}
|
|
}
|
|
MessageType::AnchorRegister => {
|
|
let payload: AnchorRegisterPayload = read_payload(recv, 4096).await?;
|
|
let mut cm = conn_mgr.lock().await;
|
|
cm.handle_anchor_register(payload).await;
|
|
}
|
|
MessageType::MeshKeepalive => {
|
|
// No-op — last_activity already updated on stream accept
|
|
}
|
|
MessageType::BlobHeaderDiff => {
|
|
let payload: BlobHeaderDiffPayload = read_payload(recv, MAX_PAYLOAD).await?;
|
|
let cm = conn_mgr.lock().await;
|
|
cm.handle_blob_header_diff(payload, remote_node_id).await;
|
|
}
|
|
MessageType::PostDownstreamRegister => {
|
|
let payload: PostDownstreamRegisterPayload = read_payload(recv, MAX_PAYLOAD).await?;
|
|
let cm = conn_mgr.lock().await;
|
|
let storage = cm.storage.lock().await;
|
|
let _ = storage.add_post_downstream(&payload.post_id, &remote_node_id);
|
|
drop(storage);
|
|
trace!(
|
|
peer = hex::encode(remote_node_id),
|
|
post = hex::encode(payload.post_id),
|
|
"Registered as post downstream"
|
|
);
|
|
}
|
|
other => {
|
|
warn!(msg_type = ?other, "Unexpected message type on uni-stream");
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_bi_stream(
|
|
conn_mgr: &Arc<Mutex<Self>>,
|
|
mut recv: iroh::endpoint::RecvStream,
|
|
send: iroh::endpoint::SendStream,
|
|
remote_node_id: NodeId,
|
|
) -> anyhow::Result<()> {
|
|
let msg_type = read_message_type(&mut recv).await?;
|
|
Self::handle_bi_stream_typed(conn_mgr, recv, send, remote_node_id, msg_type).await
|
|
}
|
|
|
|
/// Handle a bi-stream where the message type has already been read.
|
|
/// Used by handle_incoming_connection (ephemeral accept loop).
|
|
pub async fn handle_bi_stream_typed(
|
|
conn_mgr: &Arc<Mutex<Self>>,
|
|
mut recv: iroh::endpoint::RecvStream,
|
|
mut send: iroh::endpoint::SendStream,
|
|
remote_node_id: NodeId,
|
|
msg_type: MessageType,
|
|
) -> anyhow::Result<()> {
|
|
match msg_type {
|
|
MessageType::PullSyncRequest => {
|
|
let cm = conn_mgr.lock().await;
|
|
cm.handle_pull_request(remote_node_id, recv, send).await?;
|
|
}
|
|
MessageType::InitialExchange => {
|
|
let (storage, our_node_id, anchor_addr, our_nat_type, our_http_capable, our_http_addr) = {
|
|
let cm = conn_mgr.lock().await;
|
|
(cm.storage_ref(), *cm.our_node_id(), cm.build_anchor_advertised_addr(), cm.nat_type(), cm.http_capable, cm.http_addr.clone())
|
|
};
|
|
initial_exchange_accept(&storage, &our_node_id, send, recv, remote_node_id, anchor_addr, None, our_nat_type, our_http_capable, our_http_addr)
|
|
.await?;
|
|
}
|
|
MessageType::AddressRequest => {
|
|
let cm = conn_mgr.lock().await;
|
|
cm.handle_address_request(recv, send, remote_node_id).await?;
|
|
}
|
|
MessageType::SocialCheckin => {
|
|
let payload: SocialCheckinPayload = read_payload(&mut recv, MAX_PAYLOAD).await?;
|
|
let reply = {
|
|
let cm = conn_mgr.lock().await;
|
|
let storage = cm.storage.lock().await;
|
|
// Update their social route
|
|
if storage.has_social_route(&payload.node_id).unwrap_or(false) {
|
|
let addrs: Vec<std::net::SocketAddr> = payload.addresses.iter()
|
|
.filter_map(|a| a.parse().ok()).collect();
|
|
let _ = storage.touch_social_route_connect(
|
|
&payload.node_id, &addrs, ReachMethod::Direct,
|
|
);
|
|
let _ = storage.update_social_route_peer_addrs(
|
|
&payload.node_id, &payload.peer_addresses,
|
|
);
|
|
}
|
|
// Build reply (gather data before dropping locks)
|
|
let our_addrs: Vec<String> = cm.endpoint.addr().ip_addrs()
|
|
.map(|s| s.to_string()).collect();
|
|
SocialCheckinPayload {
|
|
node_id: cm.our_node_id,
|
|
addresses: our_addrs,
|
|
peer_addresses: vec![],
|
|
}
|
|
};
|
|
write_typed_message(&mut send, MessageType::SocialCheckin, &reply).await?;
|
|
send.finish()?;
|
|
debug!(peer = hex::encode(remote_node_id), "Handled social checkin");
|
|
}
|
|
MessageType::WormQuery => {
|
|
let payload: WormQueryPayload = read_payload(&mut recv, MAX_PAYLOAD).await?;
|
|
debug!(
|
|
peer = hex::encode(remote_node_id),
|
|
target = hex::encode(payload.target),
|
|
ttl = payload.ttl,
|
|
"Received worm query"
|
|
);
|
|
let mut cm = conn_mgr.lock().await;
|
|
cm.handle_worm_query(payload, send, remote_node_id).await?;
|
|
}
|
|
MessageType::PostFetchRequest => {
|
|
let payload: crate::protocol::PostFetchRequestPayload = read_payload(&mut recv, 4096).await?;
|
|
debug!(
|
|
peer = hex::encode(remote_node_id),
|
|
post = hex::encode(payload.post_id),
|
|
"Received PostFetch request"
|
|
);
|
|
let cm = conn_mgr.lock().await;
|
|
let result = {
|
|
let store = cm.storage.lock().await;
|
|
store.get_post_with_visibility(&payload.post_id).ok().flatten()
|
|
};
|
|
let resp = if let Some((post, visibility)) = result {
|
|
if matches!(visibility, PostVisibility::Public) {
|
|
crate::protocol::PostFetchResponsePayload {
|
|
post_id: payload.post_id,
|
|
found: true,
|
|
post: Some(SyncPost {
|
|
id: payload.post_id,
|
|
post,
|
|
visibility,
|
|
}),
|
|
}
|
|
} else {
|
|
crate::protocol::PostFetchResponsePayload {
|
|
post_id: payload.post_id,
|
|
found: false,
|
|
post: None,
|
|
}
|
|
}
|
|
} else {
|
|
crate::protocol::PostFetchResponsePayload {
|
|
post_id: payload.post_id,
|
|
found: false,
|
|
post: None,
|
|
}
|
|
};
|
|
write_typed_message(&mut send, MessageType::PostFetchResponse, &resp).await?;
|
|
send.finish()?;
|
|
}
|
|
MessageType::TcpPunchRequest => {
|
|
let payload: crate::protocol::TcpPunchRequestPayload = read_payload(&mut recv, 4096).await?;
|
|
info!(
|
|
peer = hex::encode(remote_node_id),
|
|
browser_ip = %payload.browser_ip,
|
|
post = hex::encode(payload.post_id),
|
|
"Received TcpPunch request"
|
|
);
|
|
// Validate: we hold this post and it's public
|
|
let (valid, http_port, http_addr) = {
|
|
let cm = conn_mgr.lock().await;
|
|
let has_post = {
|
|
let store = cm.storage.lock().await;
|
|
store.get_post_with_visibility(&payload.post_id)
|
|
.ok().flatten()
|
|
.map(|(_, v)| matches!(v, PostVisibility::Public))
|
|
.unwrap_or(false)
|
|
};
|
|
let port = cm.endpoint.bound_sockets().first()
|
|
.map(|s| s.port()).unwrap_or(0);
|
|
(has_post && cm.http_capable, port, cm.http_addr.clone())
|
|
};
|
|
let resp = if valid {
|
|
// Parse browser IP and execute TCP punch
|
|
if let Ok(browser_ip) = payload.browser_ip.parse::<std::net::IpAddr>() {
|
|
let punched = crate::http::tcp_punch(http_port, browser_ip).await;
|
|
crate::protocol::TcpPunchResultPayload {
|
|
success: punched,
|
|
http_addr,
|
|
}
|
|
} else {
|
|
crate::protocol::TcpPunchResultPayload { success: false, http_addr: None }
|
|
}
|
|
} else {
|
|
crate::protocol::TcpPunchResultPayload { success: false, http_addr: None }
|
|
};
|
|
write_typed_message(&mut send, MessageType::TcpPunchResult, &resp).await?;
|
|
send.finish()?;
|
|
}
|
|
MessageType::BlobRequest => {
|
|
let payload: BlobRequestPayload = read_payload(&mut recv, 4096).await?;
|
|
let cm = conn_mgr.lock().await;
|
|
let data = cm.blob_store.get(&payload.cid)?;
|
|
let response = match data {
|
|
Some(bytes) => {
|
|
use base64::Engine;
|
|
|
|
// Load manifest if available, wrap in CdnManifest
|
|
let storage = cm.storage.lock().await;
|
|
let manifest: Option<crate::types::CdnManifest> = storage
|
|
.get_cdn_manifest(&payload.cid)
|
|
.ok()
|
|
.flatten()
|
|
.and_then(|json| {
|
|
// Try as AuthorManifest first (author-side), then as CdnManifest (relay)
|
|
if let Ok(am) = serde_json::from_str::<crate::types::AuthorManifest>(&json) {
|
|
let ds_count = storage.get_blob_downstream_count(&payload.cid).unwrap_or(0);
|
|
Some(crate::types::CdnManifest {
|
|
author_manifest: am,
|
|
host: cm.our_node_id,
|
|
host_addresses: vec![], // Filled by caller if needed
|
|
source: cm.our_node_id,
|
|
source_addresses: vec![],
|
|
downstream_count: ds_count,
|
|
})
|
|
} else {
|
|
// Already a CdnManifest (from a relay/fetch)
|
|
serde_json::from_str(&json).ok()
|
|
}
|
|
});
|
|
|
|
// Try to register requester as downstream
|
|
let (cdn_registered, cdn_redirect_peers) = if !payload.requester_addresses.is_empty() {
|
|
let ok = storage.add_blob_downstream(
|
|
&payload.cid,
|
|
&remote_node_id,
|
|
&payload.requester_addresses,
|
|
).unwrap_or(false);
|
|
if ok {
|
|
(true, vec![])
|
|
} else {
|
|
// Full — provide downstream list as redirect candidates
|
|
let downstream = storage.get_blob_downstream(&payload.cid).unwrap_or_default();
|
|
let redirects: Vec<PeerWithAddress> = downstream.into_iter()
|
|
.map(|(nid, addrs)| PeerWithAddress {
|
|
n: hex::encode(nid),
|
|
a: addrs,
|
|
})
|
|
.collect();
|
|
(false, redirects)
|
|
}
|
|
} else {
|
|
(false, vec![])
|
|
};
|
|
drop(storage);
|
|
|
|
BlobResponsePayload {
|
|
cid: payload.cid,
|
|
found: true,
|
|
data_b64: base64::engine::general_purpose::STANDARD.encode(&bytes),
|
|
manifest,
|
|
cdn_registered,
|
|
cdn_redirect_peers,
|
|
}
|
|
}
|
|
None => BlobResponsePayload {
|
|
cid: payload.cid,
|
|
found: false,
|
|
data_b64: String::new(),
|
|
manifest: None,
|
|
cdn_registered: false,
|
|
cdn_redirect_peers: vec![],
|
|
},
|
|
};
|
|
drop(cm);
|
|
// 15MB limit for base64 overhead on 10MB blobs + manifest
|
|
write_typed_message(&mut send, MessageType::BlobResponse, &response).await?;
|
|
send.finish()?;
|
|
debug!(peer = hex::encode(remote_node_id), found = response.found, cdn_reg = response.cdn_registered, "Handled blob request");
|
|
}
|
|
MessageType::ManifestRefreshRequest => {
|
|
let payload: crate::protocol::ManifestRefreshRequestPayload = read_payload(&mut recv, 1024).await?;
|
|
let cm = conn_mgr.lock().await;
|
|
let storage = cm.storage.lock().await;
|
|
let response = match storage.get_cdn_manifest(&payload.cid).ok().flatten() {
|
|
Some(json) => {
|
|
// Build CdnManifest from stored AuthorManifest
|
|
let manifest = if let Ok(am) = serde_json::from_str::<crate::types::AuthorManifest>(&json) {
|
|
if am.updated_at > payload.current_updated_at {
|
|
let ds_count = storage.get_blob_downstream_count(&payload.cid).unwrap_or(0);
|
|
Some(crate::types::CdnManifest {
|
|
author_manifest: am,
|
|
host: cm.our_node_id,
|
|
host_addresses: vec![],
|
|
source: cm.our_node_id,
|
|
source_addresses: vec![],
|
|
downstream_count: ds_count,
|
|
})
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
crate::protocol::ManifestRefreshResponsePayload {
|
|
cid: payload.cid,
|
|
updated: manifest.is_some(),
|
|
manifest,
|
|
}
|
|
}
|
|
None => crate::protocol::ManifestRefreshResponsePayload {
|
|
cid: payload.cid,
|
|
updated: false,
|
|
manifest: None,
|
|
},
|
|
};
|
|
drop(storage);
|
|
drop(cm);
|
|
write_typed_message(&mut send, MessageType::ManifestRefreshResponse, &response).await?;
|
|
send.finish()?;
|
|
debug!(peer = hex::encode(remote_node_id), updated = response.updated, "Handled manifest refresh request");
|
|
}
|
|
MessageType::GroupKeyRequest => {
|
|
let payload: GroupKeyRequestPayload = read_payload(&mut recv, 4096).await?;
|
|
let cm = conn_mgr.lock().await;
|
|
let storage = cm.storage.lock().await;
|
|
|
|
let response = match storage.get_group_key(&payload.group_id)? {
|
|
Some(record) if record.admin == cm.our_node_id => {
|
|
// We're the admin — check if requester is still a circle member
|
|
let members = storage.get_circle_members(&record.circle_name)?;
|
|
if members.contains(&remote_node_id) {
|
|
// Wrap group seed for requester
|
|
let seed = storage.get_group_seed(&payload.group_id, record.epoch)?;
|
|
let member_key = if let Some(seed) = seed {
|
|
crypto::wrap_group_key_for_member(
|
|
&cm.secret_seed,
|
|
&remote_node_id,
|
|
&seed,
|
|
).ok().map(|wrapped| crate::types::GroupMemberKey {
|
|
member: remote_node_id,
|
|
epoch: record.epoch,
|
|
wrapped_group_key: wrapped,
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
GroupKeyResponsePayload {
|
|
group_id: payload.group_id,
|
|
epoch: record.epoch,
|
|
group_public_key: record.group_public_key,
|
|
admin: cm.our_node_id,
|
|
member_key,
|
|
}
|
|
} else {
|
|
// Requester not a member — no key
|
|
GroupKeyResponsePayload {
|
|
group_id: payload.group_id,
|
|
epoch: record.epoch,
|
|
group_public_key: record.group_public_key,
|
|
admin: cm.our_node_id,
|
|
member_key: None,
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
// Not admin or no record
|
|
GroupKeyResponsePayload {
|
|
group_id: payload.group_id,
|
|
epoch: 0,
|
|
group_public_key: [0u8; 32],
|
|
admin: cm.our_node_id,
|
|
member_key: None,
|
|
}
|
|
}
|
|
};
|
|
drop(storage);
|
|
drop(cm);
|
|
write_typed_message(&mut send, MessageType::GroupKeyResponse, &response).await?;
|
|
send.finish()?;
|
|
debug!(peer = hex::encode(remote_node_id), group_id = hex::encode(payload.group_id), "Handled group key request");
|
|
}
|
|
MessageType::RelayIntroduce => {
|
|
let payload: RelayIntroducePayload = read_payload(&mut recv, MAX_PAYLOAD).await?;
|
|
debug!(
|
|
peer = hex::encode(remote_node_id),
|
|
target = hex::encode(payload.target),
|
|
requester = hex::encode(payload.requester),
|
|
ttl = payload.ttl,
|
|
"Received relay introduce"
|
|
);
|
|
let cm_arc = Arc::clone(conn_mgr);
|
|
let mut cm = conn_mgr.lock().await;
|
|
cm.handle_relay_introduce(payload, send, remote_node_id, cm_arc).await?;
|
|
}
|
|
MessageType::SessionRelay => {
|
|
let cm = Arc::clone(conn_mgr);
|
|
Self::handle_session_relay(cm, recv, send, remote_node_id).await?;
|
|
}
|
|
MessageType::MeshPrefer => {
|
|
let mut cm = conn_mgr.lock().await;
|
|
cm.handle_mesh_prefer(remote_node_id, send, recv).await?;
|
|
}
|
|
MessageType::AnchorReferralRequest => {
|
|
let payload: AnchorReferralRequestPayload = read_payload(&mut recv, 4096).await?;
|
|
let mut cm = conn_mgr.lock().await;
|
|
cm.handle_anchor_referral_request(payload, send).await?;
|
|
}
|
|
MessageType::AnchorProbeRequest => {
|
|
let payload: crate::protocol::AnchorProbeRequestPayload = read_payload(&mut recv, 4096).await?;
|
|
let mut cm = conn_mgr.lock().await;
|
|
cm.handle_anchor_probe_request(payload, send).await?;
|
|
}
|
|
MessageType::NatFilterProbe => {
|
|
let payload: crate::protocol::NatFilterProbePayload = read_payload(&mut recv, 256).await?;
|
|
// Only hold lock briefly to look up observed address, then release before 2s probe
|
|
let observed_addr = {
|
|
let cm = conn_mgr.lock().await;
|
|
cm.connections.get(&payload.node_id)
|
|
.and_then(|pc| pc.remote_addr)
|
|
.or_else(|| cm.sessions.get(&payload.node_id).and_then(|s| s.remote_addr))
|
|
};
|
|
ConnectionManager::handle_nat_filter_probe_static(payload, observed_addr, send).await?;
|
|
}
|
|
MessageType::BlobHeaderRequest => {
|
|
let payload: BlobHeaderRequestPayload = read_payload(&mut recv, MAX_PAYLOAD).await?;
|
|
let (header_json, _updated_at) = {
|
|
let cm = conn_mgr.lock().await;
|
|
let storage = cm.storage.lock().await;
|
|
match storage.get_blob_header(&payload.post_id) {
|
|
Ok(Some((json, ts))) if ts > payload.current_updated_at => (Some(json), ts),
|
|
Ok(_) => (None, 0),
|
|
Err(_) => (None, 0),
|
|
}
|
|
};
|
|
let response = BlobHeaderResponsePayload {
|
|
post_id: payload.post_id,
|
|
updated: header_json.is_some(),
|
|
header_json,
|
|
};
|
|
write_typed_message(&mut send, MessageType::BlobHeaderResponse, &response).await?;
|
|
}
|
|
other => {
|
|
warn!(msg_type = ?other, "Unexpected message type on bi-stream");
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Handle an incoming BlobHeaderDiff — store engagement ops and re-propagate to downstream + upstream.
|
|
async fn handle_blob_header_diff(&self, payload: BlobHeaderDiffPayload, sender: NodeId) {
|
|
use crate::types::BlobHeaderDiffOp;
|
|
|
|
// Gather policy + audience data, then drop lock immediately
|
|
let (policy, approved_audience, downstream, upstream) = {
|
|
let storage = self.storage.lock().await;
|
|
let policy = storage.get_comment_policy(&payload.post_id)
|
|
.ok()
|
|
.flatten()
|
|
.unwrap_or_default();
|
|
let approved = storage.list_audience(
|
|
crate::types::AudienceDirection::Inbound,
|
|
Some(crate::types::AudienceStatus::Approved),
|
|
).unwrap_or_default();
|
|
let downstream = storage.get_post_downstream(&payload.post_id).unwrap_or_default();
|
|
let upstream = storage.get_post_upstream(&payload.post_id).ok().flatten();
|
|
(policy, approved, downstream, upstream)
|
|
};
|
|
|
|
// Filter ops using gathered data (no lock held)
|
|
let audience_set: std::collections::HashSet<NodeId> = approved_audience.iter().map(|a| a.node_id).collect();
|
|
|
|
// Apply ops in a short lock acquisition
|
|
{
|
|
let storage = self.storage.lock().await;
|
|
for op in &payload.ops {
|
|
match op {
|
|
BlobHeaderDiffOp::AddReaction(reaction) => {
|
|
if policy.blocklist.contains(&reaction.reactor) {
|
|
continue;
|
|
}
|
|
if let crate::types::ReactPermission::None = policy.allow_reacts {
|
|
continue;
|
|
}
|
|
let _ = storage.store_reaction(reaction);
|
|
}
|
|
BlobHeaderDiffOp::RemoveReaction { reactor, emoji, post_id } => {
|
|
let _ = storage.remove_reaction(reactor, post_id, emoji);
|
|
}
|
|
BlobHeaderDiffOp::AddComment(comment) => {
|
|
if policy.blocklist.contains(&comment.author) {
|
|
continue;
|
|
}
|
|
match policy.allow_comments {
|
|
crate::types::CommentPermission::None => continue,
|
|
crate::types::CommentPermission::AudienceOnly => {
|
|
if !audience_set.contains(&comment.author) {
|
|
continue;
|
|
}
|
|
}
|
|
crate::types::CommentPermission::Public => {}
|
|
}
|
|
let _ = storage.store_comment(comment);
|
|
}
|
|
BlobHeaderDiffOp::SetPolicy(new_policy) => {
|
|
if sender == payload.author {
|
|
let _ = storage.set_comment_policy(&payload.post_id, new_policy);
|
|
}
|
|
}
|
|
BlobHeaderDiffOp::ThreadSplit { new_post_id } => {
|
|
let _ = storage.store_thread_meta(&crate::types::ThreadMeta {
|
|
post_id: *new_post_id,
|
|
parent_post_id: payload.post_id,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for peer_id in downstream {
|
|
if peer_id == sender {
|
|
continue;
|
|
}
|
|
// Try mesh connection first, then session
|
|
let conn = self.connections.get(&peer_id).map(|mc| mc.connection.clone())
|
|
.or_else(|| self.sessions.get(&peer_id).map(|sc| sc.connection.clone()));
|
|
if let Some(conn) = conn {
|
|
let payload_clone = payload.clone();
|
|
tokio::spawn(async move {
|
|
if let Ok(mut send) = conn.open_uni().await {
|
|
let _ = write_typed_message(&mut send, MessageType::BlobHeaderDiff, &payload_clone).await;
|
|
let _ = send.finish();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Also propagate upstream (toward the author)
|
|
if let Some(up) = upstream {
|
|
if up != sender {
|
|
let conn = self.connections.get(&up).map(|mc| mc.connection.clone())
|
|
.or_else(|| self.sessions.get(&up).map(|sc| sc.connection.clone()));
|
|
if let Some(conn) = conn {
|
|
let payload_clone = payload.clone();
|
|
tokio::spawn(async move {
|
|
if let Ok(mut send) = conn.open_uni().await {
|
|
let _ = write_typed_message(&mut send, MessageType::BlobHeaderDiff, &payload_clone).await;
|
|
let _ = send.finish();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Get the endpoint reference.
|
|
pub fn endpoint(&self) -> &iroh::Endpoint {
|
|
&self.endpoint
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// ConnHandle + ConnectionActor: actor-based interface to ConnectionManager
|
|
// ============================================================================
|
|
|
|
use tokio::sync::{mpsc, oneshot};
|
|
|
|
/// Response types for actor commands that return data.
|
|
pub enum ConnResponse {
|
|
Bool(bool),
|
|
Usize(usize),
|
|
NodeId(NodeId),
|
|
OptConnection(Option<iroh::endpoint::Connection>),
|
|
Peers(Vec<NodeId>),
|
|
ConnectionInfo(Vec<(NodeId, PeerSlotKind, u64)>),
|
|
SessionInfo(Vec<(NodeId, SessionReachMethod, u64)>),
|
|
NatProfile(crate::types::NatProfile),
|
|
NatType(crate::types::NatType),
|
|
NatMapping(crate::types::NatMapping),
|
|
NatFiltering(crate::types::NatFiltering),
|
|
OptString(Option<String>),
|
|
OptEndpointAddr(Option<iroh::EndpointAddr>),
|
|
Endpoint(iroh::Endpoint),
|
|
SecretSeed([u8; 32]),
|
|
Storage(Arc<Mutex<Storage>>),
|
|
BlobStore(Arc<BlobStore>),
|
|
ActiveRelayPipes(Arc<AtomicU64>),
|
|
Referrals(Vec<AnchorReferral>),
|
|
OptSocketAddr(Option<SocketAddr>),
|
|
IsAnchorAtomicBool(Arc<AtomicBool>),
|
|
ActivityLogRef(Arc<std::sync::Mutex<ActivityLog>>),
|
|
ScoreVec(Vec<(NodeId, f64)>),
|
|
OptPeerWithAddress(Option<PeerWithAddress>),
|
|
ConnectionMap(Vec<(NodeId, iroh::endpoint::Connection, PeerSlotKind, Arc<AtomicU64>)>),
|
|
DiffData(DiffSnapshot),
|
|
Unit,
|
|
}
|
|
|
|
/// Snapshot of data needed for routing diff computation.
|
|
pub struct DiffSnapshot {
|
|
pub our_node_id: NodeId,
|
|
pub connections: Vec<(NodeId, iroh::endpoint::Connection)>,
|
|
pub diff_seq: u64,
|
|
pub n1_added: Vec<NodeId>,
|
|
pub n1_removed: Vec<NodeId>,
|
|
pub n2_added: Vec<NodeId>,
|
|
pub n2_removed: Vec<NodeId>,
|
|
}
|
|
|
|
/// Commands sent to the ConnectionActor via the ConnHandle channel.
|
|
pub enum ConnCommand {
|
|
// --- Reads ---
|
|
IsConnected {
|
|
peer: NodeId,
|
|
reply: oneshot::Sender<bool>,
|
|
},
|
|
ConnectionCount {
|
|
reply: oneshot::Sender<usize>,
|
|
},
|
|
ConnectedPeers {
|
|
reply: oneshot::Sender<Vec<NodeId>>,
|
|
},
|
|
ConnectionInfo {
|
|
reply: oneshot::Sender<Vec<(NodeId, PeerSlotKind, u64)>>,
|
|
},
|
|
GetConnection {
|
|
peer: NodeId,
|
|
reply: oneshot::Sender<Option<iroh::endpoint::Connection>>,
|
|
},
|
|
GetAnyConnection {
|
|
peer: NodeId,
|
|
reply: oneshot::Sender<Option<iroh::endpoint::Connection>>,
|
|
},
|
|
/// Get all mesh connections (node_id, connection, slot_kind, last_activity)
|
|
GetConnectionMap {
|
|
reply: oneshot::Sender<Vec<(NodeId, iroh::endpoint::Connection, PeerSlotKind, Arc<AtomicU64>)>>,
|
|
},
|
|
OurNodeId {
|
|
reply: oneshot::Sender<NodeId>,
|
|
},
|
|
OurNatProfile {
|
|
reply: oneshot::Sender<crate::types::NatProfile>,
|
|
},
|
|
NatType {
|
|
reply: oneshot::Sender<crate::types::NatType>,
|
|
},
|
|
NatMapping {
|
|
reply: oneshot::Sender<crate::types::NatMapping>,
|
|
},
|
|
NatFiltering {
|
|
reply: oneshot::Sender<crate::types::NatFiltering>,
|
|
},
|
|
SessionInfo {
|
|
reply: oneshot::Sender<Vec<(NodeId, SessionReachMethod, u64)>>,
|
|
},
|
|
HasSession {
|
|
peer: NodeId,
|
|
reply: oneshot::Sender<bool>,
|
|
},
|
|
IsConnectedOrSession {
|
|
peer: NodeId,
|
|
reply: oneshot::Sender<bool>,
|
|
},
|
|
SessionPeerIds {
|
|
reply: oneshot::Sender<Vec<NodeId>>,
|
|
},
|
|
AvailableLocalSlots {
|
|
reply: oneshot::Sender<usize>,
|
|
},
|
|
IsLikelyUnreachable {
|
|
peer: NodeId,
|
|
reply: oneshot::Sender<bool>,
|
|
},
|
|
GetEndpoint {
|
|
reply: oneshot::Sender<iroh::Endpoint>,
|
|
},
|
|
GetSecretSeed {
|
|
reply: oneshot::Sender<[u8; 32]>,
|
|
},
|
|
GetStorage {
|
|
reply: oneshot::Sender<Arc<Mutex<Storage>>>,
|
|
},
|
|
GetBlobStore {
|
|
reply: oneshot::Sender<Arc<BlobStore>>,
|
|
},
|
|
ActiveRelayPipes {
|
|
reply: oneshot::Sender<Arc<AtomicU64>>,
|
|
},
|
|
CanAcceptRelayPipe {
|
|
reply: oneshot::Sender<bool>,
|
|
},
|
|
BuildAnchorAdvertisedAddr {
|
|
reply: oneshot::Sender<Option<String>>,
|
|
},
|
|
ResolvePeerAddrLocal {
|
|
peer: NodeId,
|
|
reply: oneshot::Sender<Option<iroh::EndpointAddr>>,
|
|
},
|
|
PickRandomRedirectPeer {
|
|
exclude: NodeId,
|
|
reply: oneshot::Sender<Option<PeerWithAddress>>,
|
|
},
|
|
IsAnchorCandidate {
|
|
reply: oneshot::Sender<bool>,
|
|
},
|
|
ProbeDue {
|
|
reply: oneshot::Sender<bool>,
|
|
},
|
|
IsAnchorRef {
|
|
reply: oneshot::Sender<Arc<AtomicBool>>,
|
|
},
|
|
GetActivityLog {
|
|
reply: oneshot::Sender<Arc<std::sync::Mutex<ActivityLog>>>,
|
|
},
|
|
ScoreN2Candidates {
|
|
reply: oneshot::Sender<Vec<(NodeId, f64)>>,
|
|
},
|
|
GetPeerObservedAddr {
|
|
peer: NodeId,
|
|
reply: oneshot::Sender<Option<SocketAddr>>,
|
|
},
|
|
GetPeerLastActivity {
|
|
peer: NodeId,
|
|
reply: oneshot::Sender<Option<Arc<AtomicU64>>>,
|
|
},
|
|
|
|
// --- Mutations (with reply) ---
|
|
AcceptConnection {
|
|
conn: iroh::endpoint::Connection,
|
|
remote_node_id: NodeId,
|
|
remote_addr: Option<SocketAddr>,
|
|
reply: oneshot::Sender<bool>,
|
|
},
|
|
RegisterConnection {
|
|
peer_id: NodeId,
|
|
conn: iroh::endpoint::Connection,
|
|
addrs: Vec<SocketAddr>,
|
|
slot_kind: PeerSlotKind,
|
|
reply: oneshot::Sender<()>,
|
|
},
|
|
DisconnectPeer {
|
|
peer: NodeId,
|
|
reply: oneshot::Sender<()>,
|
|
},
|
|
AddSession {
|
|
node_id: NodeId,
|
|
conn: iroh::endpoint::Connection,
|
|
reach_method: SessionReachMethod,
|
|
remote_addr: Option<SocketAddr>,
|
|
reply: oneshot::Sender<()>,
|
|
},
|
|
RemoveSession {
|
|
peer: NodeId,
|
|
reply: oneshot::Sender<()>,
|
|
},
|
|
TouchSession {
|
|
peer: NodeId,
|
|
reply: oneshot::Sender<()>,
|
|
},
|
|
ReapIdleSessions {
|
|
idle_timeout_ms: u64,
|
|
reply: oneshot::Sender<()>,
|
|
},
|
|
MarkReachable {
|
|
peer: NodeId,
|
|
},
|
|
MarkUnreachable {
|
|
peer: NodeId,
|
|
},
|
|
SetNatFiltering {
|
|
filtering: crate::types::NatFiltering,
|
|
},
|
|
AddStickyN1 {
|
|
peer: NodeId,
|
|
duration_ms: u64,
|
|
},
|
|
SetGrowthTx {
|
|
tx: tokio::sync::mpsc::Sender<()>,
|
|
},
|
|
SetRecoveryTx {
|
|
tx: tokio::sync::mpsc::Sender<()>,
|
|
},
|
|
|
|
// --- Fire-and-forget ---
|
|
NotifyGrowth,
|
|
NotifyRecovery,
|
|
LogActivity {
|
|
level: ActivityLevel,
|
|
cat: ActivityCategory,
|
|
msg: String,
|
|
peer: Option<NodeId>,
|
|
},
|
|
|
|
// --- Dedup checks ---
|
|
CheckSeenWorm {
|
|
worm_id: WormId,
|
|
reply: oneshot::Sender<bool>,
|
|
},
|
|
CheckSeenIntro {
|
|
intro_id: IntroId,
|
|
reply: oneshot::Sender<bool>,
|
|
},
|
|
CheckSeenProbe {
|
|
probe_id: [u8; 16],
|
|
reply: oneshot::Sender<bool>,
|
|
},
|
|
RecordProbeSuccess,
|
|
RecordProbeFailure,
|
|
|
|
// --- Referral management ---
|
|
HandleAnchorRegister {
|
|
payload: AnchorRegisterPayload,
|
|
reply: oneshot::Sender<()>,
|
|
},
|
|
PickReferrals {
|
|
exclude: NodeId,
|
|
count: usize,
|
|
reply: oneshot::Sender<Vec<AnchorReferral>>,
|
|
},
|
|
MarkReferralDisconnected {
|
|
node_id: NodeId,
|
|
},
|
|
MarkReferralReconnected {
|
|
node_id: NodeId,
|
|
},
|
|
|
|
// --- Diff/routing ---
|
|
ProcessRoutingDiff {
|
|
from_peer: NodeId,
|
|
payload: NodeListUpdatePayload,
|
|
reply: oneshot::Sender<anyhow::Result<usize>>,
|
|
},
|
|
/// Get snapshot of connections + diff data for external broadcast
|
|
GetDiffData {
|
|
reply: oneshot::Sender<DiffSnapshot>,
|
|
},
|
|
|
|
// --- Relay/address lookups (state reads, no network I/O) ---
|
|
FindRelaysFor {
|
|
target: NodeId,
|
|
reply: oneshot::Sender<Vec<(NodeId, u8)>>,
|
|
},
|
|
GetUpnpExternalAddr {
|
|
reply: oneshot::Sender<Option<SocketAddr>>,
|
|
},
|
|
TouchSessionIfExists {
|
|
peer: NodeId,
|
|
},
|
|
|
|
// --- Complex operations (actor processes, may block command queue during I/O) ---
|
|
RebalanceSlots {
|
|
reply: oneshot::Sender<anyhow::Result<Vec<NodeId>>>,
|
|
},
|
|
WormLookup {
|
|
target: NodeId,
|
|
reply: oneshot::Sender<anyhow::Result<Option<WormResult>>>,
|
|
},
|
|
ContentSearch {
|
|
target: NodeId,
|
|
post_id: Option<PostId>,
|
|
blob_id: Option<[u8; 32]>,
|
|
reply: oneshot::Sender<anyhow::Result<Option<WormResult>>>,
|
|
},
|
|
PostFetch {
|
|
holder: NodeId,
|
|
post_id: PostId,
|
|
reply: oneshot::Sender<anyhow::Result<Option<crate::protocol::SyncPost>>>,
|
|
},
|
|
ResolveAddress {
|
|
target: NodeId,
|
|
reply: oneshot::Sender<anyhow::Result<Option<String>>>,
|
|
},
|
|
PullFromPeer {
|
|
peer: NodeId,
|
|
reply: oneshot::Sender<anyhow::Result<PullSyncStats>>,
|
|
},
|
|
FetchEngagement {
|
|
peer: NodeId,
|
|
reply: oneshot::Sender<anyhow::Result<usize>>,
|
|
},
|
|
InitiateAnchorProbe {
|
|
reply: oneshot::Sender<anyhow::Result<bool>>,
|
|
},
|
|
TcpPunch {
|
|
holder: NodeId,
|
|
browser_ip: String,
|
|
post_id: PostId,
|
|
reply: oneshot::Sender<anyhow::Result<Option<String>>>,
|
|
},
|
|
}
|
|
|
|
/// Cheap-to-clone handle for sending commands to the ConnectionActor.
|
|
/// Replaces `Arc<Mutex<ConnectionManager>>` at call sites.
|
|
#[derive(Clone)]
|
|
pub struct ConnHandle {
|
|
tx: mpsc::Sender<ConnCommand>,
|
|
/// Whether this node's HTTP server is running (set once at startup)
|
|
http_capable: Arc<AtomicBool>,
|
|
/// External HTTP address if known (set once at startup)
|
|
http_addr: Arc<std::sync::Mutex<Option<String>>>,
|
|
}
|
|
|
|
impl ConnHandle {
|
|
/// Create a ConnHandle from a command channel sender.
|
|
pub fn new(tx: mpsc::Sender<ConnCommand>) -> Self {
|
|
Self {
|
|
tx,
|
|
http_capable: Arc::new(AtomicBool::new(false)),
|
|
http_addr: Arc::new(std::sync::Mutex::new(None)),
|
|
}
|
|
}
|
|
|
|
/// Set HTTP capability info (called once after HTTP server starts).
|
|
pub fn set_http_info(&self, capable: bool, addr: Option<String>) {
|
|
self.http_capable.store(capable, Ordering::Relaxed);
|
|
*self.http_addr.lock().unwrap() = addr;
|
|
}
|
|
|
|
/// Whether this node is HTTP-capable.
|
|
pub fn is_http_capable(&self) -> bool {
|
|
self.http_capable.load(Ordering::Relaxed)
|
|
}
|
|
|
|
/// External HTTP address if known.
|
|
pub fn http_addr(&self) -> Option<String> {
|
|
self.http_addr.lock().unwrap().clone()
|
|
}
|
|
|
|
// === Read operations ===
|
|
|
|
pub async fn is_connected(&self, peer: &NodeId) -> bool {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::IsConnected { peer: *peer, reply: tx }).await;
|
|
rx.await.unwrap_or(false)
|
|
}
|
|
|
|
pub async fn connection_count(&self) -> usize {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::ConnectionCount { reply: tx }).await;
|
|
rx.await.unwrap_or(0)
|
|
}
|
|
|
|
pub async fn connected_peers(&self) -> Vec<NodeId> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::ConnectedPeers { reply: tx }).await;
|
|
rx.await.unwrap_or_default()
|
|
}
|
|
|
|
pub async fn connection_info(&self) -> Vec<(NodeId, PeerSlotKind, u64)> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::ConnectionInfo { reply: tx }).await;
|
|
rx.await.unwrap_or_default()
|
|
}
|
|
|
|
pub async fn get_connection(&self, peer: &NodeId) -> Option<iroh::endpoint::Connection> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::GetConnection { peer: *peer, reply: tx }).await;
|
|
rx.await.ok().flatten()
|
|
}
|
|
|
|
pub async fn get_any_connection(&self, peer: &NodeId) -> Option<iroh::endpoint::Connection> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::GetAnyConnection { peer: *peer, reply: tx }).await;
|
|
rx.await.ok().flatten()
|
|
}
|
|
|
|
/// Get all mesh connections as (node_id, connection, slot_kind, last_activity).
|
|
pub async fn get_connection_map(&self) -> Vec<(NodeId, iroh::endpoint::Connection, PeerSlotKind, Arc<AtomicU64>)> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::GetConnectionMap { reply: tx }).await;
|
|
rx.await.unwrap_or_default()
|
|
}
|
|
|
|
pub async fn our_node_id(&self) -> NodeId {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::OurNodeId { reply: tx }).await;
|
|
rx.await.unwrap_or([0u8; 32])
|
|
}
|
|
|
|
pub async fn our_nat_profile(&self) -> crate::types::NatProfile {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::OurNatProfile { reply: tx }).await;
|
|
rx.await.unwrap_or_else(|_| crate::types::NatProfile::new(
|
|
crate::types::NatMapping::EndpointIndependent,
|
|
crate::types::NatFiltering::Unknown,
|
|
))
|
|
}
|
|
|
|
pub async fn nat_type(&self) -> crate::types::NatType {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::NatType { reply: tx }).await;
|
|
rx.await.unwrap_or(crate::types::NatType::Unknown)
|
|
}
|
|
|
|
pub async fn nat_filtering(&self) -> crate::types::NatFiltering {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::NatFiltering { reply: tx }).await;
|
|
rx.await.unwrap_or(crate::types::NatFiltering::Unknown)
|
|
}
|
|
|
|
pub async fn session_info(&self) -> Vec<(NodeId, SessionReachMethod, u64)> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::SessionInfo { reply: tx }).await;
|
|
rx.await.unwrap_or_default()
|
|
}
|
|
|
|
pub async fn has_session(&self, peer: &NodeId) -> bool {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::HasSession { peer: *peer, reply: tx }).await;
|
|
rx.await.unwrap_or(false)
|
|
}
|
|
|
|
pub async fn is_connected_or_session(&self, peer: &NodeId) -> bool {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::IsConnectedOrSession { peer: *peer, reply: tx }).await;
|
|
rx.await.unwrap_or(false)
|
|
}
|
|
|
|
pub async fn session_peer_ids(&self) -> Vec<NodeId> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::SessionPeerIds { reply: tx }).await;
|
|
rx.await.unwrap_or_default()
|
|
}
|
|
|
|
pub async fn available_local_slots(&self) -> usize {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::AvailableLocalSlots { reply: tx }).await;
|
|
rx.await.unwrap_or(0)
|
|
}
|
|
|
|
pub async fn is_likely_unreachable(&self, peer: &NodeId) -> bool {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::IsLikelyUnreachable { peer: *peer, reply: tx }).await;
|
|
rx.await.unwrap_or(false)
|
|
}
|
|
|
|
pub async fn endpoint(&self) -> iroh::Endpoint {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::GetEndpoint { reply: tx }).await;
|
|
rx.await.expect("actor dropped")
|
|
}
|
|
|
|
pub async fn secret_seed(&self) -> [u8; 32] {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::GetSecretSeed { reply: tx }).await;
|
|
rx.await.unwrap_or([0u8; 32])
|
|
}
|
|
|
|
pub async fn storage(&self) -> Arc<Mutex<Storage>> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::GetStorage { reply: tx }).await;
|
|
rx.await.expect("actor dropped")
|
|
}
|
|
|
|
pub async fn blob_store(&self) -> Arc<BlobStore> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::GetBlobStore { reply: tx }).await;
|
|
rx.await.expect("actor dropped")
|
|
}
|
|
|
|
pub async fn active_relay_pipes(&self) -> Arc<AtomicU64> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::ActiveRelayPipes { reply: tx }).await;
|
|
rx.await.expect("actor dropped")
|
|
}
|
|
|
|
pub async fn can_accept_relay_pipe(&self) -> bool {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::CanAcceptRelayPipe { reply: tx }).await;
|
|
rx.await.unwrap_or(false)
|
|
}
|
|
|
|
pub async fn build_anchor_advertised_addr(&self) -> Option<String> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::BuildAnchorAdvertisedAddr { reply: tx }).await;
|
|
rx.await.ok().flatten()
|
|
}
|
|
|
|
pub async fn resolve_peer_addr_local(&self, peer: &NodeId) -> Option<iroh::EndpointAddr> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::ResolvePeerAddrLocal { peer: *peer, reply: tx }).await;
|
|
rx.await.ok().flatten()
|
|
}
|
|
|
|
pub async fn pick_random_redirect_peer(&self, exclude: &NodeId) -> Option<PeerWithAddress> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::PickRandomRedirectPeer { exclude: *exclude, reply: tx }).await;
|
|
rx.await.ok().flatten()
|
|
}
|
|
|
|
pub async fn is_anchor_candidate(&self) -> bool {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::IsAnchorCandidate { reply: tx }).await;
|
|
rx.await.unwrap_or(false)
|
|
}
|
|
|
|
pub async fn probe_due(&self) -> bool {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::ProbeDue { reply: tx }).await;
|
|
rx.await.unwrap_or(false)
|
|
}
|
|
|
|
pub async fn is_anchor_ref(&self) -> Arc<AtomicBool> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::IsAnchorRef { reply: tx }).await;
|
|
rx.await.expect("actor dropped")
|
|
}
|
|
|
|
pub async fn activity_log(&self) -> Arc<std::sync::Mutex<ActivityLog>> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::GetActivityLog { reply: tx }).await;
|
|
rx.await.expect("actor dropped")
|
|
}
|
|
|
|
pub async fn score_n2_candidates(&self) -> Vec<(NodeId, f64)> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::ScoreN2Candidates { reply: tx }).await;
|
|
rx.await.unwrap_or_default()
|
|
}
|
|
|
|
pub async fn get_peer_observed_addr(&self, peer: &NodeId) -> Option<SocketAddr> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::GetPeerObservedAddr { peer: *peer, reply: tx }).await;
|
|
rx.await.ok().flatten()
|
|
}
|
|
|
|
pub async fn get_peer_last_activity(&self, peer: &NodeId) -> Option<Arc<AtomicU64>> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::GetPeerLastActivity { peer: *peer, reply: tx }).await;
|
|
rx.await.ok().flatten()
|
|
}
|
|
|
|
// === Mutation operations ===
|
|
|
|
pub async fn accept_connection(
|
|
&self,
|
|
conn: iroh::endpoint::Connection,
|
|
remote_node_id: NodeId,
|
|
remote_addr: Option<SocketAddr>,
|
|
) -> bool {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::AcceptConnection {
|
|
conn,
|
|
remote_node_id,
|
|
remote_addr,
|
|
reply: tx,
|
|
}).await;
|
|
rx.await.unwrap_or(false)
|
|
}
|
|
|
|
pub async fn register_connection(
|
|
&self,
|
|
peer_id: NodeId,
|
|
conn: iroh::endpoint::Connection,
|
|
addrs: Vec<SocketAddr>,
|
|
slot_kind: PeerSlotKind,
|
|
) {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::RegisterConnection {
|
|
peer_id,
|
|
conn,
|
|
addrs,
|
|
slot_kind,
|
|
reply: tx,
|
|
}).await;
|
|
let _ = rx.await;
|
|
}
|
|
|
|
pub async fn disconnect_peer(&self, peer: &NodeId) {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::DisconnectPeer { peer: *peer, reply: tx }).await;
|
|
let _ = rx.await;
|
|
}
|
|
|
|
pub async fn add_session(
|
|
&self,
|
|
node_id: NodeId,
|
|
conn: iroh::endpoint::Connection,
|
|
reach_method: SessionReachMethod,
|
|
remote_addr: Option<SocketAddr>,
|
|
) {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::AddSession {
|
|
node_id,
|
|
conn,
|
|
reach_method,
|
|
remote_addr,
|
|
reply: tx,
|
|
}).await;
|
|
let _ = rx.await;
|
|
}
|
|
|
|
pub async fn remove_session(&self, peer: &NodeId) {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::RemoveSession { peer: *peer, reply: tx }).await;
|
|
let _ = rx.await;
|
|
}
|
|
|
|
pub async fn touch_session(&self, peer: &NodeId) {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::TouchSession { peer: *peer, reply: tx }).await;
|
|
let _ = rx.await;
|
|
}
|
|
|
|
pub async fn reap_idle_sessions(&self, idle_timeout_ms: u64) {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::ReapIdleSessions { idle_timeout_ms, reply: tx }).await;
|
|
let _ = rx.await;
|
|
}
|
|
|
|
pub fn mark_reachable(&self, peer: &NodeId) {
|
|
let _ = self.tx.try_send(ConnCommand::MarkReachable { peer: *peer });
|
|
}
|
|
|
|
pub fn mark_unreachable(&self, peer: &NodeId) {
|
|
let _ = self.tx.try_send(ConnCommand::MarkUnreachable { peer: *peer });
|
|
}
|
|
|
|
/// Add a node to the sticky N1 set (reported in N1 share until expiry).
|
|
pub fn add_sticky_n1(&self, peer: &NodeId, duration_ms: u64) {
|
|
let _ = self.tx.try_send(ConnCommand::AddStickyN1 { peer: *peer, duration_ms });
|
|
}
|
|
|
|
pub fn set_nat_filtering(&self, filtering: crate::types::NatFiltering) {
|
|
let _ = self.tx.try_send(ConnCommand::SetNatFiltering { filtering });
|
|
}
|
|
|
|
pub async fn set_growth_tx(&self, tx: tokio::sync::mpsc::Sender<()>) {
|
|
let _ = self.tx.send(ConnCommand::SetGrowthTx { tx }).await;
|
|
}
|
|
|
|
pub async fn set_recovery_tx(&self, tx: tokio::sync::mpsc::Sender<()>) {
|
|
let _ = self.tx.send(ConnCommand::SetRecoveryTx { tx }).await;
|
|
}
|
|
|
|
// === Fire-and-forget ===
|
|
|
|
pub fn notify_growth(&self) {
|
|
let _ = self.tx.try_send(ConnCommand::NotifyGrowth);
|
|
}
|
|
|
|
pub fn notify_recovery(&self) {
|
|
let _ = self.tx.try_send(ConnCommand::NotifyRecovery);
|
|
}
|
|
|
|
pub fn log_activity(&self, level: ActivityLevel, cat: ActivityCategory, msg: String, peer: Option<NodeId>) {
|
|
let _ = self.tx.try_send(ConnCommand::LogActivity { level, cat, msg, peer });
|
|
}
|
|
|
|
// === Dedup checks ===
|
|
|
|
pub async fn check_seen_worm(&self, worm_id: &WormId) -> bool {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::CheckSeenWorm { worm_id: *worm_id, reply: tx }).await;
|
|
rx.await.unwrap_or(false)
|
|
}
|
|
|
|
pub async fn check_seen_intro(&self, intro_id: &IntroId) -> bool {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::CheckSeenIntro { intro_id: *intro_id, reply: tx }).await;
|
|
rx.await.unwrap_or(false)
|
|
}
|
|
|
|
pub async fn check_seen_probe(&self, probe_id: &[u8; 16]) -> bool {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::CheckSeenProbe { probe_id: *probe_id, reply: tx }).await;
|
|
rx.await.unwrap_or(false)
|
|
}
|
|
|
|
pub fn record_probe_success(&self) {
|
|
let _ = self.tx.try_send(ConnCommand::RecordProbeSuccess);
|
|
}
|
|
|
|
pub fn record_probe_failure(&self) {
|
|
let _ = self.tx.try_send(ConnCommand::RecordProbeFailure);
|
|
}
|
|
|
|
// === Referral management ===
|
|
|
|
pub async fn handle_anchor_register(&self, payload: AnchorRegisterPayload) {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::HandleAnchorRegister { payload, reply: tx }).await;
|
|
let _ = rx.await;
|
|
}
|
|
|
|
pub async fn pick_referrals(&self, exclude: &NodeId, count: usize) -> Vec<AnchorReferral> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::PickReferrals { exclude: *exclude, count, reply: tx }).await;
|
|
rx.await.unwrap_or_default()
|
|
}
|
|
|
|
pub fn mark_referral_disconnected(&self, node_id: &NodeId) {
|
|
let _ = self.tx.try_send(ConnCommand::MarkReferralDisconnected { node_id: *node_id });
|
|
}
|
|
|
|
pub fn mark_referral_reconnected(&self, node_id: &NodeId) {
|
|
let _ = self.tx.try_send(ConnCommand::MarkReferralReconnected { node_id: *node_id });
|
|
}
|
|
|
|
// === Diff/routing ===
|
|
|
|
pub async fn process_routing_diff(
|
|
&self,
|
|
from_peer: &NodeId,
|
|
payload: NodeListUpdatePayload,
|
|
) -> anyhow::Result<usize> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::ProcessRoutingDiff {
|
|
from_peer: *from_peer,
|
|
payload,
|
|
reply: tx,
|
|
}).await;
|
|
rx.await.map_err(|_| anyhow::anyhow!("actor dropped"))?
|
|
}
|
|
|
|
pub async fn find_relays_for(&self, target: &NodeId) -> Vec<(NodeId, u8)> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::FindRelaysFor { target: *target, reply: tx }).await;
|
|
rx.await.unwrap_or_default()
|
|
}
|
|
|
|
pub async fn upnp_external_addr(&self) -> Option<SocketAddr> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::GetUpnpExternalAddr { reply: tx }).await;
|
|
rx.await.ok().flatten()
|
|
}
|
|
|
|
/// Touch session last_active (fire-and-forget, no-op if not a session peer).
|
|
pub fn touch_session_if_exists(&self, peer: &NodeId) {
|
|
let _ = self.tx.try_send(ConnCommand::TouchSessionIfExists { peer: *peer });
|
|
}
|
|
|
|
pub async fn get_diff_data(&self) -> DiffSnapshot {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::GetDiffData { reply: tx }).await;
|
|
rx.await.unwrap_or_else(|_| DiffSnapshot {
|
|
our_node_id: [0u8; 32],
|
|
connections: vec![],
|
|
diff_seq: 0,
|
|
n1_added: vec![],
|
|
n1_removed: vec![],
|
|
n2_added: vec![],
|
|
n2_removed: vec![],
|
|
})
|
|
}
|
|
|
|
// === Complex operations ===
|
|
|
|
pub async fn rebalance_slots(&self) -> anyhow::Result<Vec<NodeId>> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::RebalanceSlots { reply: tx }).await;
|
|
rx.await.map_err(|_| anyhow::anyhow!("actor dropped"))?
|
|
}
|
|
|
|
pub async fn worm_lookup(&self, target: &NodeId) -> anyhow::Result<Option<WormResult>> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::WormLookup { target: *target, reply: tx }).await;
|
|
rx.await.map_err(|_| anyhow::anyhow!("actor dropped"))?
|
|
}
|
|
|
|
pub async fn content_search(
|
|
&self,
|
|
target: &NodeId,
|
|
post_id: Option<PostId>,
|
|
blob_id: Option<[u8; 32]>,
|
|
) -> anyhow::Result<Option<WormResult>> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::ContentSearch {
|
|
target: *target,
|
|
post_id,
|
|
blob_id,
|
|
reply: tx,
|
|
}).await;
|
|
rx.await.map_err(|_| anyhow::anyhow!("actor dropped"))?
|
|
}
|
|
|
|
pub async fn post_fetch(
|
|
&self,
|
|
holder: &NodeId,
|
|
post_id: &PostId,
|
|
) -> anyhow::Result<Option<crate::protocol::SyncPost>> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::PostFetch {
|
|
holder: *holder,
|
|
post_id: *post_id,
|
|
reply: tx,
|
|
}).await;
|
|
rx.await.map_err(|_| anyhow::anyhow!("actor dropped"))?
|
|
}
|
|
|
|
pub async fn tcp_punch(
|
|
&self,
|
|
holder: &NodeId,
|
|
browser_ip: String,
|
|
post_id: &PostId,
|
|
) -> anyhow::Result<Option<String>> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::TcpPunch {
|
|
holder: *holder,
|
|
browser_ip,
|
|
post_id: *post_id,
|
|
reply: tx,
|
|
}).await;
|
|
rx.await.map_err(|_| anyhow::anyhow!("actor dropped"))?
|
|
}
|
|
|
|
pub async fn resolve_address(&self, target: &NodeId) -> anyhow::Result<Option<String>> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::ResolveAddress { target: *target, reply: tx }).await;
|
|
rx.await.map_err(|_| anyhow::anyhow!("actor dropped"))?
|
|
}
|
|
|
|
pub async fn pull_from_peer(&self, peer: &NodeId) -> anyhow::Result<PullSyncStats> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::PullFromPeer { peer: *peer, reply: tx }).await;
|
|
rx.await.map_err(|_| anyhow::anyhow!("actor dropped"))?
|
|
}
|
|
|
|
pub async fn fetch_engagement_from_peer(&self, peer: &NodeId) -> anyhow::Result<usize> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::FetchEngagement { peer: *peer, reply: tx }).await;
|
|
rx.await.map_err(|_| anyhow::anyhow!("actor dropped"))?
|
|
}
|
|
|
|
pub async fn initiate_anchor_probe(&self) -> anyhow::Result<bool> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let _ = self.tx.send(ConnCommand::InitiateAnchorProbe { reply: tx }).await;
|
|
rx.await.map_err(|_| anyhow::anyhow!("actor dropped"))?
|
|
}
|
|
}
|
|
|
|
/// The actor task that processes commands by locking the shared ConnectionManager.
|
|
pub struct ConnectionActor {
|
|
cm: Arc<Mutex<ConnectionManager>>,
|
|
rx: mpsc::Receiver<ConnCommand>,
|
|
}
|
|
|
|
impl ConnectionActor {
|
|
/// Spawn the actor wrapping a shared Arc<Mutex<CM>>, returning a ConnHandle.
|
|
/// During migration, both the actor and legacy lock-callers share state.
|
|
pub fn spawn_with_arc(cm: Arc<Mutex<ConnectionManager>>) -> ConnHandle {
|
|
let (tx, rx) = mpsc::channel(256);
|
|
let actor = ConnectionActor { cm, rx };
|
|
tokio::spawn(actor.run());
|
|
ConnHandle::new(tx)
|
|
}
|
|
|
|
/// Spawn the actor owning the ConnectionManager directly (Phase 5+).
|
|
pub fn spawn(cm: ConnectionManager) -> ConnHandle {
|
|
Self::spawn_with_arc(Arc::new(Mutex::new(cm)))
|
|
}
|
|
|
|
async fn run(mut self) {
|
|
while let Some(cmd) = self.rx.recv().await {
|
|
self.handle(cmd).await;
|
|
}
|
|
debug!("ConnectionActor shutting down");
|
|
}
|
|
|
|
async fn handle(&mut self, cmd: ConnCommand) {
|
|
match cmd {
|
|
// --- Reads ---
|
|
ConnCommand::IsConnected { peer, reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(cm.is_connected(&peer));
|
|
}
|
|
ConnCommand::ConnectionCount { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(cm.connection_count());
|
|
}
|
|
ConnCommand::ConnectedPeers { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(cm.connected_peers());
|
|
}
|
|
ConnCommand::ConnectionInfo { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(cm.connection_info());
|
|
}
|
|
ConnCommand::GetConnection { peer, reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let conn = cm.connections_ref().get(&peer).map(|pc| pc.connection.clone());
|
|
let _ = reply.send(conn);
|
|
}
|
|
ConnCommand::GetAnyConnection { peer, reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(cm.get_any_connection(&peer));
|
|
}
|
|
ConnCommand::GetConnectionMap { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let map: Vec<_> = cm.connections_ref().iter().map(|(nid, pc)| {
|
|
(*nid, pc.connection.clone(), pc.slot_kind, Arc::clone(&pc.last_activity))
|
|
}).collect();
|
|
let _ = reply.send(map);
|
|
}
|
|
ConnCommand::OurNodeId { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(*cm.our_node_id());
|
|
}
|
|
ConnCommand::OurNatProfile { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(cm.our_nat_profile());
|
|
}
|
|
ConnCommand::NatType { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(cm.nat_type());
|
|
}
|
|
ConnCommand::NatMapping { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(cm.nat_mapping());
|
|
}
|
|
ConnCommand::NatFiltering { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(cm.nat_filtering);
|
|
}
|
|
ConnCommand::SessionInfo { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(cm.session_info());
|
|
}
|
|
ConnCommand::HasSession { peer, reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(cm.has_session(&peer));
|
|
}
|
|
ConnCommand::IsConnectedOrSession { peer, reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(cm.is_connected_or_session(&peer));
|
|
}
|
|
ConnCommand::SessionPeerIds { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(cm.session_peer_ids());
|
|
}
|
|
ConnCommand::AvailableLocalSlots { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(cm.available_local_slots());
|
|
}
|
|
ConnCommand::IsLikelyUnreachable { peer, reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(cm.is_likely_unreachable(&peer));
|
|
}
|
|
ConnCommand::GetEndpoint { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(cm.endpoint().clone());
|
|
}
|
|
ConnCommand::GetSecretSeed { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(cm.secret_seed);
|
|
}
|
|
ConnCommand::GetStorage { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(cm.storage_ref());
|
|
}
|
|
ConnCommand::GetBlobStore { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(Arc::clone(&cm.blob_store));
|
|
}
|
|
ConnCommand::ActiveRelayPipes { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(Arc::clone(cm.active_relay_pipes()));
|
|
}
|
|
ConnCommand::CanAcceptRelayPipe { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(cm.can_accept_relay_pipe());
|
|
}
|
|
ConnCommand::BuildAnchorAdvertisedAddr { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(cm.build_anchor_advertised_addr());
|
|
}
|
|
ConnCommand::ResolvePeerAddrLocal { peer, reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let r = cm.resolve_peer_addr_local(&peer).await;
|
|
let _ = reply.send(r);
|
|
}
|
|
ConnCommand::PickRandomRedirectPeer { exclude, reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let r = cm.pick_random_redirect_peer(&exclude).await;
|
|
let _ = reply.send(r);
|
|
}
|
|
ConnCommand::IsAnchorCandidate { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(cm.is_anchor_candidate());
|
|
}
|
|
ConnCommand::ProbeDue { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(cm.probe_due());
|
|
}
|
|
ConnCommand::IsAnchorRef { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(Arc::clone(&cm.is_anchor));
|
|
}
|
|
ConnCommand::GetActivityLog { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(Arc::clone(&cm.activity_log));
|
|
}
|
|
ConnCommand::ScoreN2Candidates { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let r = cm.score_n2_candidates().await;
|
|
let _ = reply.send(r);
|
|
}
|
|
ConnCommand::GetPeerObservedAddr { peer, reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let addr = cm.connections_ref().get(&peer)
|
|
.and_then(|pc| pc.remote_addr);
|
|
let _ = reply.send(addr);
|
|
}
|
|
|
|
// --- Mutations ---
|
|
ConnCommand::AcceptConnection { conn, remote_node_id, remote_addr, reply } => {
|
|
let mut cm = self.cm.lock().await;
|
|
let r = cm.accept_connection(conn, remote_node_id, remote_addr);
|
|
let _ = reply.send(r);
|
|
}
|
|
ConnCommand::RegisterConnection { peer_id, conn, addrs, slot_kind, reply } => {
|
|
let mut cm = self.cm.lock().await;
|
|
cm.register_connection(peer_id, conn, &addrs, slot_kind).await;
|
|
let _ = reply.send(());
|
|
}
|
|
ConnCommand::DisconnectPeer { peer, reply } => {
|
|
let mut cm = self.cm.lock().await;
|
|
cm.disconnect_peer(&peer).await;
|
|
let _ = reply.send(());
|
|
}
|
|
ConnCommand::AddSession { node_id, conn, reach_method, remote_addr, reply } => {
|
|
let mut cm = self.cm.lock().await;
|
|
cm.add_session(node_id, conn, reach_method, remote_addr);
|
|
let _ = reply.send(());
|
|
}
|
|
ConnCommand::RemoveSession { peer, reply } => {
|
|
let mut cm = self.cm.lock().await;
|
|
cm.remove_session(&peer);
|
|
let _ = reply.send(());
|
|
}
|
|
ConnCommand::TouchSession { peer, reply } => {
|
|
let mut cm = self.cm.lock().await;
|
|
cm.touch_session(&peer);
|
|
let _ = reply.send(());
|
|
}
|
|
ConnCommand::ReapIdleSessions { idle_timeout_ms, reply } => {
|
|
let mut cm = self.cm.lock().await;
|
|
cm.reap_idle_sessions(idle_timeout_ms).await;
|
|
let _ = reply.send(());
|
|
}
|
|
ConnCommand::MarkReachable { peer } => {
|
|
let mut cm = self.cm.lock().await;
|
|
cm.mark_reachable(&peer);
|
|
}
|
|
ConnCommand::MarkUnreachable { peer } => {
|
|
let mut cm = self.cm.lock().await;
|
|
cm.mark_unreachable(&peer);
|
|
}
|
|
ConnCommand::SetNatFiltering { filtering } => {
|
|
let mut cm = self.cm.lock().await;
|
|
cm.nat_filtering = filtering;
|
|
}
|
|
ConnCommand::AddStickyN1 { peer, duration_ms } => {
|
|
let mut cm = self.cm.lock().await;
|
|
let expiry = now_ms() + duration_ms;
|
|
cm.sticky_n1.insert(peer, expiry);
|
|
}
|
|
ConnCommand::SetGrowthTx { tx } => {
|
|
let mut cm = self.cm.lock().await;
|
|
cm.set_growth_tx(tx);
|
|
}
|
|
ConnCommand::SetRecoveryTx { tx } => {
|
|
let mut cm = self.cm.lock().await;
|
|
cm.set_recovery_tx(tx);
|
|
}
|
|
|
|
// --- Fire-and-forget ---
|
|
ConnCommand::NotifyGrowth => {
|
|
let cm = self.cm.lock().await;
|
|
cm.notify_growth();
|
|
}
|
|
ConnCommand::NotifyRecovery => {
|
|
let cm = self.cm.lock().await;
|
|
cm.notify_recovery();
|
|
}
|
|
ConnCommand::LogActivity { level, cat, msg, peer } => {
|
|
let cm = self.cm.lock().await;
|
|
cm.log_activity(level, cat, msg, peer);
|
|
}
|
|
|
|
// --- Dedup checks ---
|
|
ConnCommand::CheckSeenWorm { worm_id, reply } => {
|
|
let now = now_ms();
|
|
let mut cm = self.cm.lock().await;
|
|
cm.seen_worms.retain(|_, ts| now - *ts < WORM_DEDUP_EXPIRY_MS);
|
|
let seen = cm.seen_worms.contains_key(&worm_id);
|
|
if !seen {
|
|
cm.seen_worms.insert(worm_id, now);
|
|
}
|
|
let _ = reply.send(seen);
|
|
}
|
|
ConnCommand::CheckSeenIntro { intro_id, reply } => {
|
|
let now = now_ms();
|
|
let mut cm = self.cm.lock().await;
|
|
cm.seen_intros.retain(|_, ts| now - *ts < RELAY_INTRO_DEDUP_EXPIRY_MS);
|
|
let seen = cm.seen_intros.contains_key(&intro_id);
|
|
if !seen {
|
|
cm.seen_intros.insert(intro_id, now);
|
|
}
|
|
let _ = reply.send(seen);
|
|
}
|
|
ConnCommand::CheckSeenProbe { probe_id, reply } => {
|
|
let now = now_ms();
|
|
let mut cm = self.cm.lock().await;
|
|
cm.seen_probes.retain(|_, ts| now - *ts < 60_000);
|
|
let seen = cm.seen_probes.contains_key(&probe_id);
|
|
if !seen {
|
|
cm.seen_probes.insert(probe_id, now);
|
|
}
|
|
let _ = reply.send(seen);
|
|
}
|
|
ConnCommand::RecordProbeSuccess => {
|
|
let mut cm = self.cm.lock().await;
|
|
cm.last_probe_success_ms = now_ms();
|
|
cm.probe_failure_streak = 0;
|
|
}
|
|
ConnCommand::RecordProbeFailure => {
|
|
let mut cm = self.cm.lock().await;
|
|
cm.probe_failure_streak = cm.probe_failure_streak.saturating_add(1);
|
|
}
|
|
|
|
// --- Referral management ---
|
|
ConnCommand::HandleAnchorRegister { payload, reply } => {
|
|
let mut cm = self.cm.lock().await;
|
|
cm.handle_anchor_register(payload).await;
|
|
let _ = reply.send(());
|
|
}
|
|
ConnCommand::PickReferrals { exclude, count, reply } => {
|
|
let mut cm = self.cm.lock().await;
|
|
let r = cm.pick_referrals(&exclude, count);
|
|
let _ = reply.send(r);
|
|
}
|
|
ConnCommand::MarkReferralDisconnected { node_id } => {
|
|
let mut cm = self.cm.lock().await;
|
|
cm.mark_referral_disconnected(&node_id);
|
|
}
|
|
ConnCommand::MarkReferralReconnected { node_id } => {
|
|
let mut cm = self.cm.lock().await;
|
|
cm.mark_referral_reconnected(&node_id);
|
|
}
|
|
|
|
// --- Diff/routing ---
|
|
ConnCommand::ProcessRoutingDiff { from_peer, payload, reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let r = cm.process_routing_diff(&from_peer, payload).await;
|
|
let _ = reply.send(r);
|
|
}
|
|
ConnCommand::GetDiffData { reply } => {
|
|
let mut cm = self.cm.lock().await;
|
|
// Prune expired sticky N1 entries first
|
|
let now = now_ms();
|
|
cm.sticky_n1.retain(|_, expiry| *expiry > now);
|
|
let sticky_peers: Vec<NodeId> = cm.sticky_n1.keys().copied().collect();
|
|
// Compute diff snapshot
|
|
let storage = cm.storage.lock().await;
|
|
let current_n1: HashSet<NodeId> = {
|
|
let mut set = HashSet::new();
|
|
for nid in cm.connections_ref().keys() {
|
|
set.insert(*nid);
|
|
}
|
|
if let Ok(routes) = storage.list_social_routes() {
|
|
for route in &routes {
|
|
set.insert(route.node_id);
|
|
}
|
|
}
|
|
for nid in &sticky_peers {
|
|
set.insert(*nid);
|
|
}
|
|
set
|
|
};
|
|
let current_n2: HashSet<NodeId> = storage.build_n2_share()
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.collect();
|
|
drop(storage);
|
|
|
|
let n1_added: Vec<NodeId> = current_n1.difference(&cm.last_n1_set).copied().collect();
|
|
let n1_removed: Vec<NodeId> = cm.last_n1_set.difference(¤t_n1).copied().collect();
|
|
let n2_added: Vec<NodeId> = current_n2.difference(&cm.last_n2_set).copied().collect();
|
|
let n2_removed: Vec<NodeId> = cm.last_n2_set.difference(¤t_n2).copied().collect();
|
|
|
|
cm.last_n1_set = current_n1;
|
|
cm.last_n2_set = current_n2;
|
|
|
|
let seq = cm.diff_seq.fetch_add(1, Ordering::Relaxed);
|
|
|
|
let conns: Vec<(NodeId, iroh::endpoint::Connection)> = cm.connections_ref()
|
|
.iter()
|
|
.map(|(nid, pc)| (*nid, pc.connection.clone()))
|
|
.collect();
|
|
|
|
let _ = reply.send(DiffSnapshot {
|
|
our_node_id: *cm.our_node_id(),
|
|
connections: conns,
|
|
diff_seq: seq,
|
|
n1_added,
|
|
n1_removed,
|
|
n2_added,
|
|
n2_removed,
|
|
});
|
|
}
|
|
|
|
// --- Relay/address lookups ---
|
|
ConnCommand::FindRelaysFor { target, reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let r = cm.find_relays_for(&target).await;
|
|
let _ = reply.send(r);
|
|
}
|
|
ConnCommand::GetUpnpExternalAddr { reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let _ = reply.send(cm.upnp_external_addr);
|
|
}
|
|
ConnCommand::TouchSessionIfExists { peer } => {
|
|
let mut cm = self.cm.lock().await;
|
|
if let Some(session) = cm.sessions.get_mut(&peer) {
|
|
session.last_active_at = now_ms();
|
|
}
|
|
}
|
|
|
|
// --- Complex operations ---
|
|
ConnCommand::RebalanceSlots { reply } => {
|
|
let mut cm = self.cm.lock().await;
|
|
let r = cm.rebalance_slots().await;
|
|
let _ = reply.send(r);
|
|
}
|
|
ConnCommand::WormLookup { target, reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let r = cm.initiate_worm_lookup(&target).await;
|
|
let _ = reply.send(r);
|
|
}
|
|
ConnCommand::ContentSearch { target, post_id, blob_id, reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let r = cm.initiate_content_search(&target, post_id, blob_id).await;
|
|
let _ = reply.send(r);
|
|
}
|
|
ConnCommand::PostFetch { holder, post_id, reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let r = cm.send_post_fetch(&holder, &post_id).await;
|
|
let _ = reply.send(r);
|
|
}
|
|
ConnCommand::TcpPunch { holder, browser_ip, post_id, reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let r = cm.send_tcp_punch(&holder, browser_ip, &post_id).await;
|
|
let _ = reply.send(r);
|
|
}
|
|
ConnCommand::ResolveAddress { target, reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let r = cm.resolve_address(&target).await;
|
|
let _ = reply.send(r);
|
|
}
|
|
ConnCommand::PullFromPeer { peer, reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let r = cm.pull_from_peer(&peer).await;
|
|
let _ = reply.send(r);
|
|
}
|
|
ConnCommand::FetchEngagement { peer, reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let r = cm.fetch_engagement_from_peer(&peer).await;
|
|
let _ = reply.send(r);
|
|
}
|
|
ConnCommand::InitiateAnchorProbe { reply } => {
|
|
let mut cm = self.cm.lock().await;
|
|
let r = cm.initiate_anchor_probe().await;
|
|
let _ = reply.send(r);
|
|
}
|
|
ConnCommand::GetPeerLastActivity { peer, reply } => {
|
|
let cm = self.cm.lock().await;
|
|
let activity = cm.connections_ref().get(&peer)
|
|
.map(|pc| Arc::clone(&pc.last_activity));
|
|
let _ = reply.send(activity);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Standalone initial exchange (connector side) — does NOT require conn_mgr lock.
|
|
/// Opens a bi-stream, sends our N1/N2/profile/deletes/post_ids, reads theirs.
|
|
pub async fn initial_exchange_connect(
|
|
storage: &Arc<Mutex<Storage>>,
|
|
our_node_id: &NodeId,
|
|
conn: &iroh::endpoint::Connection,
|
|
remote_node_id: NodeId,
|
|
anchor_addr: Option<String>,
|
|
our_nat_type: crate::types::NatType,
|
|
our_http_capable: bool,
|
|
our_http_addr: Option<String>,
|
|
) -> anyhow::Result<ExchangeResult> {
|
|
let our_payload = {
|
|
let storage = storage.lock().await;
|
|
let n1 = storage.build_n1_share()?;
|
|
let n2 = storage.build_n2_share()?;
|
|
let profile = storage.get_profile(our_node_id)?;
|
|
let deletes = storage.list_delete_records()?;
|
|
let post_ids = storage.list_post_ids()?;
|
|
let peer_addresses = storage.build_peer_addresses_for(our_node_id)?;
|
|
InitialExchangePayload {
|
|
n1_node_ids: n1,
|
|
n2_node_ids: n2,
|
|
profile,
|
|
deletes,
|
|
post_ids,
|
|
peer_addresses,
|
|
anchor_addr,
|
|
your_observed_addr: None, // connector doesn't know remote's observed addr
|
|
nat_type: Some(our_nat_type.to_string()),
|
|
nat_mapping: Some(crate::types::NatProfile::from_nat_type(our_nat_type).mapping.to_string()),
|
|
nat_filtering: Some(crate::types::NatProfile::from_nat_type(our_nat_type).filtering.to_string()),
|
|
http_capable: our_http_capable,
|
|
http_addr: our_http_addr,
|
|
}
|
|
};
|
|
|
|
let (mut send, mut recv) = conn.open_bi().await?;
|
|
write_typed_message(&mut send, MessageType::InitialExchange, &our_payload).await?;
|
|
send.finish()?;
|
|
|
|
// 10s timeout: if the remote doesn't respond, bail rather than hanging forever
|
|
let exchange_fut = async {
|
|
let msg_type = read_message_type(&mut recv).await?;
|
|
if msg_type == MessageType::RefuseRedirect {
|
|
let payload: RefuseRedirectPayload = read_payload(&mut recv, MAX_PAYLOAD).await?;
|
|
return Ok(ExchangeResult::Refused { redirect: payload.redirect });
|
|
}
|
|
if msg_type != MessageType::InitialExchange {
|
|
anyhow::bail!("expected InitialExchange, got {:?}", msg_type);
|
|
}
|
|
let their_payload: InitialExchangePayload = read_payload(&mut recv, MAX_PAYLOAD).await?;
|
|
process_exchange_payload(storage, our_node_id, &remote_node_id, &their_payload).await?;
|
|
Ok(ExchangeResult::Accepted)
|
|
};
|
|
|
|
match tokio::time::timeout(std::time::Duration::from_secs(10), exchange_fut).await {
|
|
Ok(result) => result,
|
|
Err(_) => {
|
|
warn!(peer = hex::encode(remote_node_id), "Initial exchange timed out (10s)");
|
|
anyhow::bail!("initial exchange timed out");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Standalone initial exchange (acceptor side) — does NOT require conn_mgr lock.
|
|
/// Message type byte already consumed by caller.
|
|
pub async fn initial_exchange_accept(
|
|
storage: &Arc<Mutex<Storage>>,
|
|
our_node_id: &NodeId,
|
|
mut send: iroh::endpoint::SendStream,
|
|
mut recv: iroh::endpoint::RecvStream,
|
|
remote_node_id: NodeId,
|
|
anchor_addr: Option<String>,
|
|
remote_addr: Option<SocketAddr>,
|
|
our_nat_type: crate::types::NatType,
|
|
our_http_capable: bool,
|
|
our_http_addr: Option<String>,
|
|
) -> anyhow::Result<()> {
|
|
let their_payload: InitialExchangePayload = read_payload(&mut recv, MAX_PAYLOAD).await?;
|
|
|
|
let our_payload = {
|
|
let storage = storage.lock().await;
|
|
let n1 = storage.build_n1_share()?;
|
|
let n2 = storage.build_n2_share()?;
|
|
let profile = storage.get_profile(our_node_id)?;
|
|
let deletes = storage.list_delete_records()?;
|
|
let post_ids = storage.list_post_ids()?;
|
|
let peer_addresses = storage.build_peer_addresses_for(our_node_id)?;
|
|
InitialExchangePayload {
|
|
n1_node_ids: n1,
|
|
n2_node_ids: n2,
|
|
profile,
|
|
deletes,
|
|
post_ids,
|
|
peer_addresses,
|
|
anchor_addr,
|
|
your_observed_addr: remote_addr.map(|a| a.to_string()),
|
|
nat_type: Some(our_nat_type.to_string()),
|
|
nat_mapping: Some(crate::types::NatProfile::from_nat_type(our_nat_type).mapping.to_string()),
|
|
nat_filtering: Some(crate::types::NatProfile::from_nat_type(our_nat_type).filtering.to_string()),
|
|
http_capable: our_http_capable,
|
|
http_addr: our_http_addr,
|
|
}
|
|
};
|
|
|
|
write_typed_message(&mut send, MessageType::InitialExchange, &our_payload).await?;
|
|
send.finish()?;
|
|
|
|
process_exchange_payload(storage, our_node_id, &remote_node_id, &their_payload).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Process the peer's initial exchange payload (shared between connect and accept sides).
|
|
async fn process_exchange_payload(
|
|
storage: &Arc<Mutex<Storage>>,
|
|
our_node_id: &NodeId,
|
|
remote_node_id: &NodeId,
|
|
payload: &InitialExchangePayload,
|
|
) -> anyhow::Result<()> {
|
|
let storage = storage.lock().await;
|
|
|
|
// Filter out our own ID from their N1 before storing as our N2
|
|
let filtered_n1: Vec<NodeId> = payload.n1_node_ids.iter()
|
|
.filter(|nid| *nid != our_node_id)
|
|
.copied()
|
|
.collect();
|
|
storage.set_peer_n1(remote_node_id, &filtered_n1)?;
|
|
debug!(peer = hex::encode(remote_node_id), raw = payload.n1_node_ids.len(), stored = filtered_n1.len(), "Stored peer N1 as our N2 (filtered self)");
|
|
|
|
let filtered_n2: Vec<NodeId> = payload.n2_node_ids.iter()
|
|
.filter(|nid| *nid != our_node_id)
|
|
.copied()
|
|
.collect();
|
|
storage.set_peer_n2(remote_node_id, &filtered_n2)?;
|
|
debug!(peer = hex::encode(remote_node_id), raw = payload.n2_node_ids.len(), stored = filtered_n2.len(), "Stored peer N2 as our N3 (filtered self)");
|
|
|
|
if let Some(ref profile) = payload.profile {
|
|
let _ = storage.store_profile(profile);
|
|
}
|
|
|
|
for dr in &payload.deletes {
|
|
if crypto::verify_delete_signature(&dr.author, &dr.post_id, &dr.signature) {
|
|
let _ = storage.store_delete(dr);
|
|
let _ = storage.apply_delete(dr);
|
|
}
|
|
}
|
|
|
|
for pa in &payload.peer_addresses {
|
|
if let Ok(nid) = crate::parse_node_id_hex(&pa.n) {
|
|
let addrs: Vec<std::net::SocketAddr> = pa.a.iter().filter_map(|a| a.parse().ok()).collect();
|
|
if !addrs.is_empty() {
|
|
let _ = storage.upsert_peer(&nid, &addrs, None);
|
|
}
|
|
}
|
|
}
|
|
|
|
let our_post_ids: HashSet<[u8; 32]> = storage.list_post_ids()?.into_iter().collect();
|
|
for pid in &payload.post_ids {
|
|
if our_post_ids.contains(pid) {
|
|
let _ = storage.record_replica(pid, remote_node_id);
|
|
}
|
|
}
|
|
|
|
// Process anchor's advertised address
|
|
if let Some(ref anchor_addr_str) = payload.anchor_addr {
|
|
if let Ok(sock) = anchor_addr_str.parse::<SocketAddr>() {
|
|
let _ = storage.upsert_known_anchor(remote_node_id, &[sock]);
|
|
let _ = storage.upsert_peer(remote_node_id, &[sock], None);
|
|
info!(peer = hex::encode(remote_node_id), addr = %sock, "Stored anchor's advertised address");
|
|
}
|
|
}
|
|
|
|
// Log observed address (STUN-like feedback)
|
|
if let Some(ref observed) = payload.your_observed_addr {
|
|
info!(observed_addr = %observed, reporter = hex::encode(remote_node_id), "Peer reports our address as");
|
|
}
|
|
|
|
// Store peer's NAT type
|
|
if let Some(ref nat_str) = payload.nat_type {
|
|
let nat = crate::types::NatType::from_str_label(nat_str);
|
|
let _ = storage.set_peer_nat_type(remote_node_id, nat);
|
|
debug!(peer = hex::encode(remote_node_id), nat_type = %nat, "Stored peer NAT type");
|
|
}
|
|
|
|
// Store peer's NAT profile (mapping + filtering) if provided
|
|
if payload.nat_mapping.is_some() || payload.nat_filtering.is_some() {
|
|
let mapping = payload.nat_mapping.as_deref()
|
|
.map(crate::types::NatMapping::from_str_label)
|
|
.unwrap_or(crate::types::NatMapping::Unknown);
|
|
let filtering = payload.nat_filtering.as_deref()
|
|
.map(crate::types::NatFiltering::from_str_label)
|
|
.unwrap_or(crate::types::NatFiltering::Unknown);
|
|
let profile = crate::types::NatProfile::new(mapping, filtering);
|
|
let _ = storage.set_peer_nat_profile(remote_node_id, &profile);
|
|
debug!(peer = hex::encode(remote_node_id), mapping = %mapping, filtering = %filtering, "Stored peer NAT profile");
|
|
}
|
|
|
|
// Store peer's HTTP capability
|
|
if payload.http_capable {
|
|
let _ = storage.set_peer_http_info(remote_node_id, true, payload.http_addr.as_deref());
|
|
debug!(peer = hex::encode(remote_node_id), http_addr = ?payload.http_addr, "Stored peer HTTP capability");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn now_ms() -> u64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis() as u64
|
|
}
|