From be253e8001331f8bc1a7665158baad053a996be2 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Sun, 5 Apr 2026 17:57:41 -0400 Subject: [PATCH] Our Info panel, hole punch race fix, NAT profiles in relay introduction - Network Diagnostics: "Our Info" button shows addresses with NAT status, device role, UPnP, HTTP capability. Addresses stacked for mobile. - Hole punch race: re-check for existing connection before and after relay introduction to avoid wasting minutes on redundant punch attempts. - Relay introduction now carries requester/target NAT mapping+filtering so hole punch strategy uses fresh profiles instead of stale stored ones. Critical for phones that switch between WiFi/cellular/VPN. - STUN fix: filter DNS results to IPv4 (was resolving to IPv6 first on dual-stack, causing silent send failure and "NAT unknown"). - Welcome screen: Ready button with loading bar for instant feed access. - LAN addresses show just "LAN" (no misleading punchability label). Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 6 +- crates/core/src/connection.rs | 51 ++++++++++++++- crates/core/src/network.rs | 38 ++++++++++- crates/core/src/protocol.rs | 18 ++++++ crates/core/src/stun.rs | 3 +- crates/tauri-app/src/lib.rs | 115 ++++++++++++++++++++++++++++++++++ frontend/app.js | 44 +++++++++++++ 7 files changed, 266 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f076bee..3b4fb10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2732,7 +2732,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "itsgoin-cli" -version = "0.3.0" +version = "0.5.0" dependencies = [ "anyhow", "hex", @@ -2744,7 +2744,7 @@ dependencies = [ [[package]] name = "itsgoin-core" -version = "0.3.0" +version = "0.5.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -2767,7 +2767,7 @@ dependencies = [ [[package]] name = "itsgoin-desktop" -version = "0.4.4" +version = "0.5.0" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index 84383c5..2b74e16 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -3868,12 +3868,15 @@ impl ConnectionManager { } } + let our_profile = self.our_nat_profile(); let payload = RelayIntroducePayload { intro_id, target: *target, requester: self.our_node_id, requester_addresses: our_addrs, ttl, + nat_mapping: Some(our_profile.mapping.to_string()), + nat_filtering: Some(our_profile.filtering.to_string()), }; let (mut send, mut recv) = pc.connection.open_bi().await?; @@ -3909,6 +3912,8 @@ impl ConnectionManager { target_addresses: vec![], relay_available: false, reject_reason: Some("duplicate intro".to_string()), + nat_mapping: None, + nat_filtering: None, }; write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?; send.finish()?; @@ -3932,12 +3937,15 @@ impl ConnectionManager { } } + let our_profile = self.our_nat_profile(); let result = RelayIntroduceResultPayload { intro_id: payload.intro_id, accepted: true, target_addresses: our_addrs, relay_available: false, reject_reason: None, + nat_mapping: Some(our_profile.mapping.to_string()), + nat_filtering: Some(our_profile.filtering.to_string()), }; write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?; send.finish()?; @@ -3963,7 +3971,20 @@ impl ConnectionManager { 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 = { + // Prefer fresh NAT profile from relay payload over stale stored profile + let peer_nat_profile = 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 fresh = crate::types::NatProfile::new(mapping, filtering); + // Update storage with fresh profile + let s = self.storage.get().await; + let _ = s.set_peer_nat_profile(&requester, &fresh); + fresh + } else { let s = self.storage.get().await; s.get_peer_nat_profile(&requester) }; @@ -4081,6 +4102,7 @@ impl ConnectionManager { target_addresses: vec![], relay_available: false, reject_reason: Some(format!("relay forward failed: {}", e)), + nat_mapping: None, nat_filtering: None, }; write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?; send.finish()?; @@ -4155,6 +4177,7 @@ impl ConnectionManager { target_addresses: vec![], relay_available: false, reject_reason: Some(format!("relay forward to session failed: {}", e)), + nat_mapping: None, nat_filtering: None, }; write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?; send.finish()?; @@ -4194,6 +4217,8 @@ impl ConnectionManager { requester: payload.requester, requester_addresses: req_addrs, ttl: payload.ttl - 1, + nat_mapping: payload.nat_mapping.clone(), + nat_filtering: payload.nat_filtering.clone(), }; let forward_result = async { @@ -4246,6 +4271,7 @@ impl ConnectionManager { target_addresses: vec![], relay_available: false, reject_reason: Some("target not reachable through relay".to_string()), + nat_mapping: None, nat_filtering: None, }; write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?; send.finish()?; @@ -4273,6 +4299,7 @@ impl ConnectionManager { target_addresses: vec![], relay_available: false, reject_reason: Some("relay at capacity".to_string()), + nat_mapping: None, nat_filtering: None, }; write_typed_message(&mut requester_send, MessageType::RelayIntroduceResult, &result).await?; requester_send.finish()?; @@ -4293,6 +4320,7 @@ impl ConnectionManager { target_addresses: vec![], relay_available: false, reject_reason: Some("target not connected to relay".to_string()), + nat_mapping: None, nat_filtering: None, }; write_typed_message(&mut requester_send, MessageType::RelayIntroduceResult, &result).await?; requester_send.finish()?; @@ -5811,6 +5839,7 @@ impl ConnectionManager { let result = RelayIntroduceResultPayload { intro_id: payload.intro_id, accepted: false, target_addresses: vec![], relay_available: false, reject_reason: Some("duplicate intro".to_string()), + nat_mapping: None, nat_filtering: None, }; write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?; send.finish()?; @@ -5836,7 +5865,22 @@ impl ConnectionManager { let our_http_capable = cm.http_capable; let our_http_addr = cm.http_addr.clone(); let our_nat_profile = cm.our_nat_profile(); - let peer_nat_profile = { let s = cm.storage.get().await; s.get_peer_nat_profile(&payload.requester) }; + // Prefer fresh NAT profile from relay payload over stale stored profile + let peer_nat_profile = 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 fresh = crate::types::NatProfile::new(mapping, filtering); + let s = cm.storage.get().await; + let _ = s.set_peer_nat_profile(&payload.requester, &fresh); + fresh + } else { + let s = cm.storage.get().await; + s.get_peer_nat_profile(&payload.requester) + }; Some(RelayGathered::WeAreTarget { our_addrs, endpoint, storage, our_node_id, our_nat_type, our_http_capable, our_http_addr, our_nat_profile, peer_nat_profile }) } else { // We are relay — gather target connection, requester observed addr, etc. @@ -5866,6 +5910,8 @@ impl ConnectionManager { let result = RelayIntroduceResultPayload { intro_id: payload.intro_id, accepted: true, target_addresses: our_addrs, relay_available: false, reject_reason: None, + nat_mapping: Some(our_nat_profile.mapping.to_string()), + nat_filtering: Some(our_nat_profile.filtering.to_string()), }; write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?; send.finish()?; @@ -5954,6 +6000,7 @@ impl ConnectionManager { let result = RelayIntroduceResultPayload { intro_id: payload.intro_id, accepted: false, target_addresses: vec![], relay_available: false, reject_reason: Some(format!("relay forward failed: {}", e_msg)), + nat_mapping: None, nat_filtering: None, }; write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?; send.finish()?; diff --git a/crates/core/src/network.rs b/crates/core/src/network.rs index 446b2ef..0566035 100644 --- a/crates/core/src/network.rs +++ b/crates/core/src/network.rs @@ -349,6 +349,11 @@ impl Network { self.device_role } + /// Get the explicit bind address (from --bind flag), if any. + pub fn bind_addr(&self) -> Option { + self.bind_addr + } + /// Whether this node can serve HTTP (has TCP reachability). pub fn is_http_capable(&self) -> bool { self.has_upnp_tcp || self.has_public_v6 || self.bind_addr.is_some() @@ -1952,7 +1957,13 @@ impl Network { } } - // 4. Try relay introduction + hole punch (no lock during I/O) + // 4. Re-check connection — peer may have connected to us while we were trying direct + if let Some(conn) = self.conn_handle.get_any_connection(peer_id).await { + debug!(peer = hex::encode(peer_id), "Peer connected to us while we were trying — using existing connection"); + return Ok(conn); + } + + // 5. Try relay introduction + hole punch (no lock during I/O) let relay_candidates = self.conn_handle.find_relays_for(peer_id).await; if relay_candidates.is_empty() { @@ -1975,10 +1986,28 @@ impl Network { self.send_relay_introduce_standalone(relay_peer, peer_id, *ttl), ).await; + // Re-check: peer may have connected while relay intro was in flight + if let Some(conn) = self.conn_handle.get_any_connection(peer_id).await { + debug!(peer = hex::encode(peer_id), "Peer connected during relay intro — using existing"); + return Ok(conn); + } + match intro_result { - Ok(Ok(result)) if result.accepted => { + Ok(Ok(ref result)) if result.accepted => { let our_profile = self.conn_handle.our_nat_profile().await; - let peer_profile = { + // Prefer fresh NAT profile from relay response over stale stored profile + let peer_profile = if result.nat_mapping.is_some() || result.nat_filtering.is_some() { + let mapping = result.nat_mapping.as_deref() + .map(crate::types::NatMapping::from_str_label) + .unwrap_or(crate::types::NatMapping::Unknown); + let filtering = result.nat_filtering.as_deref() + .map(crate::types::NatFiltering::from_str_label) + .unwrap_or(crate::types::NatFiltering::Unknown); + let fresh = crate::types::NatProfile::new(mapping, filtering); + let s = self.storage.get().await; + let _ = s.set_peer_nat_profile(peer_id, &fresh); + fresh + } else { let s = self.storage.get().await; s.get_peer_nat_profile(peer_id) }; @@ -2274,12 +2303,15 @@ impl Network { } } + let our_profile = self.conn_handle.our_nat_profile().await; let payload = crate::protocol::RelayIntroducePayload { intro_id, target: *target, requester: self.our_node_id, requester_addresses: our_addrs, ttl, + nat_mapping: Some(our_profile.mapping.to_string()), + nat_filtering: Some(our_profile.filtering.to_string()), }; let (mut send, mut recv) = conn.open_bi().await?; diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs index 50e2565..8fdd71c 100644 --- a/crates/core/src/protocol.rs +++ b/crates/core/src/protocol.rs @@ -461,6 +461,12 @@ pub struct RelayIntroducePayload { pub requester_addresses: Vec, /// Max forwarding hops remaining (0 = relay must know target directly) pub ttl: u8, + /// Requester's current NAT mapping type (for hole punch strategy) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nat_mapping: Option, + /// Requester's current NAT filtering type + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nat_filtering: Option, } /// Target's response to a relay introduction (bi-stream response) @@ -472,6 +478,12 @@ pub struct RelayIntroduceResultPayload { /// Relay is willing to serve as stream relay fallback pub relay_available: bool, pub reject_reason: Option, + /// Target's current NAT mapping type (for hole punch strategy) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nat_mapping: Option, + /// Target's current NAT filtering type + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nat_filtering: Option, } /// Open a relay pipe — intermediary splices two bi-streams (bi-stream) @@ -858,6 +870,8 @@ mod tests { requester: [2u8; 32], requester_addresses: vec!["10.0.0.2:4433".to_string()], ttl: 1, + nat_mapping: Some("eim".to_string()), + nat_filtering: Some("open".to_string()), }; let json = serde_json::to_string(&payload).unwrap(); let decoded: RelayIntroducePayload = serde_json::from_str(&json).unwrap(); @@ -876,6 +890,8 @@ mod tests { target_addresses: vec!["10.0.0.1:4433".to_string(), "192.168.1.1:4433".to_string()], relay_available: true, reject_reason: None, + nat_mapping: Some("eim".to_string()), + nat_filtering: Some("open".to_string()), }; let json = serde_json::to_string(&payload).unwrap(); let decoded: RelayIntroduceResultPayload = serde_json::from_str(&json).unwrap(); @@ -892,6 +908,8 @@ mod tests { target_addresses: vec![], relay_available: false, reject_reason: Some("target not reachable".to_string()), + nat_mapping: None, + nat_filtering: None, }; let json2 = serde_json::to_string(&rejected).unwrap(); let decoded2: RelayIntroduceResultPayload = serde_json::from_str(&json2).unwrap(); diff --git a/crates/core/src/stun.rs b/crates/core/src/stun.rs index 35284df..792f378 100644 --- a/crates/core/src/stun.rs +++ b/crates/core/src/stun.rs @@ -103,8 +103,9 @@ fn parse_xor_mapped_address(resp: &[u8], txn_id: &[u8; 12]) -> Option Option { use std::net::ToSocketAddrs; + // Filter for IPv4 since our socket is bound to 0.0.0.0 (IPv4) let server_addr = match server.to_socket_addrs() { - Ok(mut addrs) => addrs.next()?, + Ok(addrs) => addrs.filter(|a| a.is_ipv4()).next()?, Err(e) => { debug!(server, error = %e, "STUN DNS resolution failed"); return None; diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 443fbcd..5ddc679 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -1623,6 +1623,120 @@ async fn get_network_summary(state: State<'_, AppNode>) -> Result, + nat_type: String, + device_role: String, + upnp: bool, + http_capable: bool, + http_addr: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct AddressInfoDto { + addr: String, + family: String, // "IPv4" or "IPv6" + status: String, // "Public", "NAT (easy)", "NAT (hard)", "UPnP", "LAN", "Server" +} + +#[tauri::command] +async fn get_our_info(state: State<'_, AppNode>) -> Result { + let node = get_node(&state).await; + let net = &node.network; + let nat_type = net.conn_handle().nat_type().await; + let has_upnp = net.has_upnp(); + let _has_public_v6 = net.has_public_v6(); + let bind_addr = net.bind_addr(); + + let mut addresses = Vec::new(); + + // Collect bound socket addresses (these are our local interfaces) + let bound: std::collections::HashSet = net.bound_sockets() + .iter() + .map(|s| s.to_string()) + .collect(); + + // Gather bound (local) addresses with classification + for sock in net.bound_sockets() { + if sock.ip().is_loopback() || sock.ip().is_unspecified() { continue; } + let family = if sock.ip().is_ipv4() { "IPv4" } else { "IPv6" }; + let status = classify_addr(&sock, &nat_type, has_upnp, bind_addr.is_some(), false); + addresses.push(AddressInfoDto { + addr: sock.to_string(), + family: family.to_string(), + status, + }); + } + + // Add UPnP external address if different from bound + if let Some(ref mapping) = net.upnp_mapping() { + let ext = mapping.external_addr; + if !addresses.iter().any(|a| a.addr == ext.to_string()) { + addresses.insert(0, AddressInfoDto { + addr: ext.to_string(), + family: if ext.ip().is_ipv4() { "IPv4" } else { "IPv6" }.to_string(), + status: "UPnP external".to_string(), + }); + } + } + + // Add iroh-discovered addresses not already listed (STUN-observed externals) + for sock in net.endpoint_addr().ip_addrs() { + if sock.ip().is_loopback() || sock.ip().is_unspecified() { continue; } + let s = sock.to_string(); + if addresses.iter().any(|a| a.addr == s) { continue; } + let family = if sock.ip().is_ipv4() { "IPv4" } else { "IPv6" }; + let is_observed = !bound.contains(&s); + let status = classify_addr(sock, &nat_type, has_upnp, bind_addr.is_some(), is_observed); + addresses.push(AddressInfoDto { + addr: s, + family: family.to_string(), + status, + }); + } + + Ok(OurInfoDto { + node_id: hex::encode(net.node_id_bytes()), + addresses, + nat_type: nat_type.to_string(), + device_role: format!("{:?}", net.device_role()), + upnp: has_upnp, + http_capable: net.is_http_capable(), + http_addr: net.http_addr(), + }) +} + +fn classify_addr(sock: &std::net::SocketAddr, nat_type: &itsgoin_core::types::NatType, has_upnp: bool, is_server: bool, is_observed: bool) -> String { + use std::net::IpAddr; + let ip = sock.ip(); + let is_v6 = ip.is_ipv6(); + let is_pub = match ip { + IpAddr::V4(v4) => !v4.is_private() && !v4.is_loopback() && !v4.is_link_local(), + IpAddr::V6(v6) => !v6.is_loopback() && { + let seg = v6.segments(); + seg[0] & 0xfe00 != 0xfe00 && seg[0] & 0xffc0 != 0xfe80 + }, + }; + if is_server { return "Server".to_string(); } + if is_pub { + if is_observed && !is_v6 { + return match nat_type { + itsgoin_core::types::NatType::Public | + itsgoin_core::types::NatType::Easy => "External · easy punch".to_string(), + itsgoin_core::types::NatType::Hard => "External · hard punch (scan)".to_string(), + itsgoin_core::types::NatType::Unknown => "External".to_string(), + }; + } + return "Public".to_string(); + } + if is_v6 { return "Link-local".to_string(); } + "LAN".to_string() +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ActivityEventDto { @@ -2387,6 +2501,7 @@ pub fn run() { sync_all, sync_from_peer, get_network_summary, + get_our_info, get_activity_log, trigger_rebalance, request_referrals, diff --git a/frontend/app.js b/frontend/app.js index b162158..bf16e44 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -2986,9 +2986,14 @@ function openDiagnostics() {
+
+