diff --git a/Cargo.lock b/Cargo.lock index 8b5b9a2..ebbbd6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2732,7 +2732,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "itsgoin-cli" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "hex", @@ -2744,7 +2744,7 @@ dependencies = [ [[package]] name = "itsgoin-core" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "base64 0.22.1", @@ -2753,8 +2753,10 @@ dependencies = [ "curve25519-dalek", "ed25519-dalek", "hex", - "igd-next", "iroh", + "jni", + "ndk-context", + "portmapper", "rand 0.9.2", "rusqlite", "serde", @@ -2767,7 +2769,7 @@ dependencies = [ [[package]] name = "itsgoin-desktop" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 98bd655..db2ce1a 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-cli" -version = "0.7.1" +version = "0.7.2" edition = "2021" [[bin]] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 4852a7e..5bf29e4 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-core" -version = "0.7.1" +version = "0.7.2" edition = "2021" [dependencies] @@ -19,7 +19,11 @@ ed25519-dalek = { version = "=3.0.0-pre.1", features = ["rand_core", "zeroize"] chacha20poly1305 = "0.10" base64 = "0.22" zip = { version = "2", default-features = false, features = ["deflate"] } -igd-next = { version = "0.16", features = ["tokio"] } +portmapper = "0.14" + +[target.'cfg(target_os = "android")'.dependencies] +jni = "0.21" +ndk-context = "0.1" [dev-dependencies] tempfile = "3" diff --git a/crates/core/src/android_wifi.rs b/crates/core/src/android_wifi.rs new file mode 100644 index 0000000..f355bd3 --- /dev/null +++ b/crates/core/src/android_wifi.rs @@ -0,0 +1,215 @@ +//! Android-only helpers for WiFi detection and MulticastLock acquisition. +//! +//! SSDP discovery (UPnP) requires receiving multicast UDP on 239.255.255.250:1900. +//! Android filters incoming multicast unless a WifiManager.MulticastLock is held. +//! These helpers acquire the lock for the duration of an SSDP attempt. +//! +//! Cellular networks (CGNAT) almost never expose UPnP/PCP gateways, so we gate +//! UPnP attempts on "is on WiFi" to avoid wasting a 3s discovery timeout on +//! every cellular startup. + +#![cfg(target_os = "android")] + +use jni::objects::{JObject, JString, JValue}; +use jni::JavaVM; +use tracing::{debug, warn}; + +/// Returns true if the active network is WiFi (or Ethernet). +/// Returns false on cellular, no-network, or any error. +pub fn is_on_wifi() -> bool { + match check_wifi_inner() { + Ok(v) => v, + Err(e) => { + debug!("Android WiFi check failed: {}", e); + false + } + } +} + +fn check_wifi_inner() -> Result { + let ctx = ndk_context::android_context(); + if ctx.vm().is_null() { + return Err("ndk_context: null JavaVM (not initialized?)".into()); + } + if ctx.context().is_null() { + return Err("ndk_context: null activity context".into()); + } + let vm = unsafe { JavaVM::from_raw(ctx.vm() as *mut _) } + .map_err(|e| format!("JavaVM init: {:?}", e))?; + let mut env = vm + .attach_current_thread() + .map_err(|e| format!("attach_current_thread: {:?}", e))?; + let activity = unsafe { JObject::from_raw(ctx.context() as *mut _) }; + + // service = activity.getSystemService(Context.CONNECTIVITY_SERVICE) + let svc_name = env + .new_string("connectivity") + .map_err(|e| format!("new_string: {:?}", e))?; + let svc = env + .call_method( + &activity, + "getSystemService", + "(Ljava/lang/String;)Ljava/lang/Object;", + &[JValue::Object(&svc_name)], + ) + .map_err(|e| format!("getSystemService: {:?}", e))? + .l() + .map_err(|e| format!("getSystemService cast: {:?}", e))?; + + // network = service.getActiveNetwork() + let network = env + .call_method(&svc, "getActiveNetwork", "()Landroid/net/Network;", &[]) + .map_err(|e| format!("getActiveNetwork: {:?}", e))? + .l() + .map_err(|e| format!("getActiveNetwork cast: {:?}", e))?; + + if network.is_null() { + return Ok(false); + } + + // caps = service.getNetworkCapabilities(network) + let caps = env + .call_method( + &svc, + "getNetworkCapabilities", + "(Landroid/net/Network;)Landroid/net/NetworkCapabilities;", + &[JValue::Object(&network)], + ) + .map_err(|e| format!("getNetworkCapabilities: {:?}", e))? + .l() + .map_err(|e| format!("getNetworkCapabilities cast: {:?}", e))?; + + if caps.is_null() { + return Ok(false); + } + + // NetworkCapabilities.TRANSPORT_WIFI = 1, TRANSPORT_ETHERNET = 3 + let has_wifi = env + .call_method(&caps, "hasTransport", "(I)Z", &[JValue::Int(1)]) + .map_err(|e| format!("hasTransport(WIFI): {:?}", e))? + .z() + .map_err(|e| format!("hasTransport(WIFI) cast: {:?}", e))?; + let has_eth = env + .call_method(&caps, "hasTransport", "(I)Z", &[JValue::Int(3)]) + .map_err(|e| format!("hasTransport(ETH): {:?}", e))? + .z() + .map_err(|e| format!("hasTransport(ETH) cast: {:?}", e))?; + + Ok(has_wifi || has_eth) +} + +/// RAII guard that holds an Android WifiManager.MulticastLock for its lifetime. +/// Release happens on Drop. +pub struct MulticastLockGuard { + vm: JavaVM, + lock: jni::objects::GlobalRef, +} + +impl MulticastLockGuard { + pub fn acquire(tag: &str) -> Option { + match Self::acquire_inner(tag) { + Ok(g) => Some(g), + Err(e) => { + debug!("MulticastLock acquire failed: {}", e); + None + } + } + } + + fn acquire_inner(tag: &str) -> Result { + let ctx = ndk_context::android_context(); + if ctx.vm().is_null() { + return Err("ndk_context: null JavaVM (not initialized?)".into()); + } + if ctx.context().is_null() { + return Err("ndk_context: null activity context".into()); + } + let vm = unsafe { JavaVM::from_raw(ctx.vm() as *mut _) } + .map_err(|e| format!("JavaVM init: {:?}", e))?; + let mut env = vm + .attach_current_thread() + .map_err(|e| format!("attach_current_thread: {:?}", e))?; + let activity = unsafe { JObject::from_raw(ctx.context() as *mut _) }; + + // wifi = activity.getApplicationContext().getSystemService(Context.WIFI_SERVICE) + // Application context is important: WifiManager from Activity context can leak the activity. + let app_ctx = env + .call_method( + &activity, + "getApplicationContext", + "()Landroid/content/Context;", + &[], + ) + .map_err(|e| format!("getApplicationContext: {:?}", e))? + .l() + .map_err(|e| format!("getApplicationContext cast: {:?}", e))?; + + let svc_name: JString = env + .new_string("wifi") + .map_err(|e| format!("new_string: {:?}", e))?; + let wifi = env + .call_method( + &app_ctx, + "getSystemService", + "(Ljava/lang/String;)Ljava/lang/Object;", + &[JValue::Object(&svc_name)], + ) + .map_err(|e| format!("getSystemService(wifi): {:?}", e))? + .l() + .map_err(|e| format!("getSystemService(wifi) cast: {:?}", e))?; + + if wifi.is_null() { + return Err("WifiManager is null".into()); + } + + // lock = wifi.createMulticastLock(tag) + let tag_str = env + .new_string(tag) + .map_err(|e| format!("new_string(tag): {:?}", e))?; + let lock = env + .call_method( + &wifi, + "createMulticastLock", + "(Ljava/lang/String;)Landroid/net/wifi/WifiManager$MulticastLock;", + &[JValue::Object(&tag_str)], + ) + .map_err(|e| format!("createMulticastLock: {:?}", e))? + .l() + .map_err(|e| format!("createMulticastLock cast: {:?}", e))?; + + // lock.setReferenceCounted(false) + let _ = env.call_method( + &lock, + "setReferenceCounted", + "(Z)V", + &[JValue::Bool(0)], + ); + + // lock.acquire() + env.call_method(&lock, "acquire", "()V", &[]) + .map_err(|e| format!("acquire: {:?}", e))?; + + let global = env + .new_global_ref(&lock) + .map_err(|e| format!("new_global_ref: {:?}", e))?; + + drop(env); + Ok(MulticastLockGuard { vm, lock: global }) + } +} + +impl Drop for MulticastLockGuard { + fn drop(&mut self) { + let env = match self.vm.attach_current_thread() { + Ok(e) => e, + Err(e) => { + warn!("MulticastLock release: attach failed: {:?}", e); + return; + } + }; + let mut env = env; + if let Err(e) = env.call_method(&self.lock, "release", "()V", &[]) { + warn!("MulticastLock release failed: {:?}", e); + } + } +} diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index 9a022a5..0892455 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -626,6 +626,11 @@ pub struct ConnectionManager { active_relay_pipes: Arc, /// Max concurrent relay pipes max_relay_pipes: usize, + /// User opt-in for session relay. Gates both serving as a relay + /// (`can_accept_relay_pipe`) and using a peer as a relay on hole-punch + /// failure (auto-fallback in node.rs). Default false — relay is opt-in. + /// Loaded from the `relay.session_relay_enabled` setting at startup. + session_relay_enabled: Arc, /// Device profile (for resource limits) #[allow(dead_code)] device_profile: DeviceProfile, @@ -707,11 +712,13 @@ impl ConnectionManager { bind_addr: Option, nat_type: crate::types::NatType, nat_mapping: crate::types::NatMapping, + session_relay_enabled: bool, ) -> Self { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; + let session_relay_enabled = Arc::new(AtomicBool::new(session_relay_enabled)); Self { connections: HashMap::new(), endpoint, @@ -732,6 +739,7 @@ impl ConnectionManager { seen_intros: HashMap::new(), active_relay_pipes: Arc::new(AtomicU64::new(0)), max_relay_pipes: profile.max_relay_pipes(), + session_relay_enabled, device_profile: profile, unreachable_peers: HashMap::new(), referral_list: HashMap::new(), @@ -3631,11 +3639,28 @@ impl ConnectionManager { &self.active_relay_pipes } - /// Check if we can accept more relay pipes. + /// Check if we can accept more relay pipes. Gated on user opt-in + /// (`relay.session_relay_enabled`) — returns false if the user has + /// not enabled serving as a session relay, regardless of capacity. pub fn can_accept_relay_pipe(&self) -> bool { + if !self.session_relay_enabled.load(Ordering::Relaxed) { + return false; + } self.active_relay_pipes.load(Ordering::Relaxed) < self.max_relay_pipes as u64 } + /// Whether the user has opted in to session relay (both serving and using). + pub fn is_session_relay_enabled(&self) -> bool { + self.session_relay_enabled.load(Ordering::Relaxed) + } + + /// Update the session-relay opt-in flag. The caller is responsible for + /// persisting the setting to storage; this only updates the in-memory + /// flag that gates the relay accept and auto-use paths. + pub fn set_session_relay_enabled(&self, enabled: bool) { + self.session_relay_enabled.store(enabled, Ordering::Relaxed); + } + /// Get our node ID. pub fn our_node_id(&self) -> &NodeId { &self.our_node_id @@ -6581,6 +6606,13 @@ pub enum ConnCommand { CanAcceptRelayPipe { reply: oneshot::Sender, }, + IsSessionRelayEnabled { + reply: oneshot::Sender, + }, + SetSessionRelayEnabled { + enabled: bool, + reply: oneshot::Sender<()>, + }, BuildAnchorAdvertisedAddr { reply: oneshot::Sender>, }, @@ -6978,6 +7010,18 @@ impl ConnHandle { rx.await.unwrap_or(false) } + pub async fn is_session_relay_enabled(&self) -> bool { + let (tx, rx) = oneshot::channel(); + let _ = self.tx.send(ConnCommand::IsSessionRelayEnabled { reply: tx }).await; + rx.await.unwrap_or(false) + } + + pub async fn set_session_relay_enabled(&self, enabled: bool) { + let (tx, rx) = oneshot::channel(); + let _ = self.tx.send(ConnCommand::SetSessionRelayEnabled { enabled, reply: tx }).await; + let _ = rx.await; + } + pub async fn build_anchor_advertised_addr(&self) -> Option { let (tx, rx) = oneshot::channel(); let _ = self.tx.send(ConnCommand::BuildAnchorAdvertisedAddr { reply: tx }).await; @@ -7902,6 +7946,15 @@ impl ConnectionActor { let cm = self.cm.lock().await; let _ = reply.send(cm.can_accept_relay_pipe()); } + ConnCommand::IsSessionRelayEnabled { reply } => { + let cm = self.cm.lock().await; + let _ = reply.send(cm.is_session_relay_enabled()); + } + ConnCommand::SetSessionRelayEnabled { enabled, reply } => { + let cm = self.cm.lock().await; + cm.set_session_relay_enabled(enabled); + let _ = reply.send(()); + } ConnCommand::BuildAnchorAdvertisedAddr { reply } => { let cm = self.cm.lock().await; let _ = reply.send(cm.build_anchor_advertised_addr()); diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index a4e6d7f..3c679a0 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,4 +1,6 @@ pub mod activity; +#[cfg(target_os = "android")] +pub mod android_wifi; pub mod blob; pub mod connection; pub mod content; diff --git a/crates/core/src/network.rs b/crates/core/src/network.rs index 282a576..6466eff 100644 --- a/crates/core/src/network.rs +++ b/crates/core/src/network.rs @@ -35,10 +35,12 @@ pub struct Network { /// Growth loop signal sender (set by start_growth_loop) growth_tx: tokio::sync::Mutex>>, activity_log: Arc>, - /// UPnP mapping result (None if no mapping or on mobile) - upnp_mapping: Option, - /// Whether UPnP TCP mapping succeeded (for HTTP serving) - has_upnp_tcp: bool, + /// UDP port mapping (UPnP/NAT-PMP/PCP) for the QUIC socket. + /// Holding this keeps the auto-renewing portmapper service alive. + upnp_mapping: Option, + /// TCP port mapping for HTTP serving, if available. Holding it keeps + /// the lease renewed by the portmapper background service. + tcp_mapping: Option, /// Whether this node has a public IPv6 address has_public_v6: bool, /// Stable bind address (from --bind flag), passed to ConnectionManager for anchor advertised address @@ -120,12 +122,18 @@ impl Network { let our_node_id = *endpoint.id().as_bytes(); - // Best-effort UPnP port mapping (desktop only, skip if --bind was used) + // Best-effort UDP port mapping (UPnP/NAT-PMP/PCP via portmapper). + // Skip if --bind was explicit (operator chose a fixed external port). + // The portmapper service auto-renews internally; we hold the + // PortMapping handle for the lifetime of the network. On Android the + // upnp module gates on active-network-is-WiFi and acquires a + // MulticastLock around UPnP/SSDP discovery for the lifetime of the + // mapping. iOS works for PCP and NAT-PMP without entitlement. let is_mobile = cfg!(target_os = "android") || cfg!(target_os = "ios"); - let upnp_mapping = if !is_mobile && bind_addr.is_none() { + let upnp_mapping = if bind_addr.is_none() { let bound_port = endpoint.bound_sockets().first() .map(|s| s.port()).unwrap_or(0); - crate::upnp::try_upnp_mapping(bound_port).await + crate::upnp::PortMapping::try_udp(bound_port).await } else { None }; @@ -215,6 +223,14 @@ impl Network { } let upnp_external_addr = upnp_mapping.as_ref().map(|m| m.external_addr); + let session_relay_enabled = { + let s = storage.get().await; + s.get_setting("relay.session_relay_enabled") + .ok() + .flatten() + .map(|v| v == "true") + .unwrap_or(false) + }; let conn_mgr = ConnectionManager::new( endpoint.clone(), Arc::clone(&storage), @@ -228,18 +244,26 @@ impl Network { bind_addr, nat_type, nat_mapping, + session_relay_enabled, ); let conn_mgr = Arc::new(Mutex::new(conn_mgr)); // Spawn actor wrapping the same Arc> (Phase 1: additive) let conn_handle = ConnectionActor::spawn_with_arc(Arc::clone(&conn_mgr)).await; - // TCP UPnP mapping for HTTP post delivery (only if UDP UPnP succeeded) - let has_upnp_tcp = if let Some(ref mapping) = upnp_mapping { - crate::upnp::try_upnp_tcp_mapping(mapping.local_port, mapping.external_addr.port()).await + // TCP port mapping for HTTP post delivery. Run on every platform — + // mobile devices with permissive NAT (UPnP/PCP-TCP working) can serve + // HTTP for direct browser fetches. We attempt independently of the + // UDP mapping result because the protocols are separate calls in + // portmapper. + let tcp_mapping = if bind_addr.is_none() { + let bound_port = endpoint.bound_sockets().first() + .map(|s| s.port()).unwrap_or(0); + crate::upnp::PortMapping::try_tcp(bound_port).await } else { - false + None }; + let has_upnp_tcp = tcp_mapping.is_some(); info!( node_id = %endpoint.id(), @@ -259,6 +283,79 @@ impl Network { info!(role = %device_role, "CDN replication role determined"); conn_handle.set_device_role(device_role); + // Anchor reachability watcher: tracks the UDP port mapping over time + // and adjusts anchor mode without restart. + // - Mapping lost (None) for >5 min → clear anchor mode (don't keep + // advertising an unreachable address). Replaces the old + // "3 consecutive UPnP renewal failures" logic. + // - Mapping restored (None → Some) → re-evaluate auto-anchor. If + // this node qualifies (desktop with the new mapping in hand), set + // anchor back on. Mobile never auto-anchors regardless. + // The watcher exits when the watch channel closes (i.e., when the + // PortMapping is dropped at network shutdown). + if let Some(ref mapping) = upnp_mapping { + let mut rx = mapping.watch_external(); + let is_anchor_w = Arc::clone(&is_anchor); + let alog_w = Arc::clone(&activity_log); + tokio::spawn(async move { + let mut down_since: Option = None; + let threshold = std::time::Duration::from_secs(300); + loop { + if rx.changed().await.is_err() { + // Channel closed — mapping was dropped, watcher done. + return; + } + let current = *rx.borrow_and_update(); + match current { + Some(addr) => { + // Mapping restored. If we'd lost anchor due to a + // previous drop, restore it now. Skip on mobile + // (cellular IPs look public but aren't anchorable). + let recovered = down_since.is_some(); + down_since = None; + if recovered && !is_mobile && !is_anchor_w.load(Ordering::Relaxed) { + is_anchor_w.store(true, Ordering::Relaxed); + if let Ok(mut log) = alog_w.try_lock() { + log.log( + ActivityLevel::Info, + ActivityCategory::Connection, + format!( + "Port mapping restored ({}), anchor mode re-enabled", + addr + ), + None, + ); + } + info!(external = %addr, "Port mapping restored — anchor mode re-enabled"); + } + } + None => { + let now = std::time::Instant::now(); + let t = *down_since.get_or_insert(now); + if now.duration_since(t) > threshold + && is_anchor_w.load(Ordering::Relaxed) + { + is_anchor_w.store(false, Ordering::Relaxed); + if let Ok(mut log) = alog_w.try_lock() { + log.log( + ActivityLevel::Warn, + ActivityCategory::Connection, + "Port mapping lost >5min, anchor mode cleared" + .into(), + None, + ); + } + warn!( + "Port mapping lost for >5min — anchor mode cleared" + ); + // Don't return — keep watching for recovery so we can re-anchor. + } + } + } + } + }); + } + Ok(Self { endpoint, storage, @@ -269,7 +366,7 @@ impl Network { growth_tx: tokio::sync::Mutex::new(None), activity_log, upnp_mapping, - has_upnp_tcp, + tcp_mapping, has_public_v6, bind_addr, device_role, @@ -333,8 +430,8 @@ impl Network { addrs } - /// Get the UPnP mapping, if one was successfully acquired. - pub fn upnp_mapping(&self) -> Option<&crate::upnp::UpnpMapping> { + /// Get the active UDP port mapping (UPnP/NAT-PMP/PCP), if any. + pub fn upnp_mapping(&self) -> Option<&crate::upnp::PortMapping> { self.upnp_mapping.as_ref() } @@ -359,7 +456,7 @@ impl Network { /// 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() + self.tcp_mapping.is_some() || self.has_public_v6 || self.bind_addr.is_some() } /// Get the port to bind the HTTP TCP listener on (same as QUIC). @@ -373,9 +470,9 @@ impl Network { } } - /// Whether UPnP TCP mapping is active. + /// Whether the TCP port mapping for HTTP serving is active. pub fn has_upnp_tcp(&self) -> bool { - self.has_upnp_tcp + self.tcp_mapping.is_some() } /// Whether this node has a public IPv6 address. @@ -383,15 +480,19 @@ impl Network { self.has_public_v6 } - /// Whether this node has UPnP mapping (UDP or TCP). + /// Whether this node has a port mapping (UDP or TCP) from any of + /// UPnP-IGD / NAT-PMP / PCP. pub fn has_upnp(&self) -> bool { - self.upnp_mapping.is_some() || self.has_upnp_tcp + self.upnp_mapping.is_some() || self.tcp_mapping.is_some() } /// Get external HTTP address string for InitialExchange advertisement. pub fn http_addr(&self) -> Option { - if let Some(ref mapping) = self.upnp_mapping { + // Prefer an explicit TCP mapping (UPnP/PCP/NAT-PMP) — that's the address + // a remote browser can reach for HTTP serving. The UDP mapping is for + // QUIC, not HTTP. + if let Some(ref mapping) = self.tcp_mapping { return Some(mapping.external_addr.to_string()); } if let Some(bind) = self.bind_addr { @@ -2152,18 +2253,20 @@ impl Network { pub async fn shutdown(self) -> anyhow::Result<()> { // Remove UPnP port mapping before closing endpoint - if let Some(ref mapping) = self.upnp_mapping { - crate::upnp::remove_upnp_mapping(mapping.external_addr.port()).await; - } + // Dropping the PortMapping triggers the portmapper Client's + // AbortOnDropHandle and stops the renewal task. We don't explicitly + // release here because `self` doesn't move; the field will drop + // naturally when the Network goes out of scope. self.endpoint.close().await; Ok(()) } /// Shutdown via Arc reference — closes the endpoint, causing all background tasks to exit. pub async fn shutdown_ref(&self) { - if let Some(ref mapping) = self.upnp_mapping { - crate::upnp::remove_upnp_mapping(mapping.external_addr.port()).await; - } + // Dropping the PortMapping triggers the portmapper Client's + // AbortOnDropHandle and stops the renewal task. We don't explicitly + // release here because `self` doesn't move; the field will drop + // naturally when the Network goes out of scope. self.endpoint.close().await; } diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 0450ab8..e6ed36d 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -3692,8 +3692,14 @@ impl Node { } } - // Step 7: Session relay fallback — if intro was accepted but hole punch failed - if let (Some(intro_id), Some(relay_peer)) = (last_intro_id, last_relay_peer) { + // Step 7: Session relay fallback — only if BOTH the introducer + // signaled relay availability AND this node has opted in to + // using session relay (`relay.session_relay_enabled`). Default + // is opt-out: hole-punch failure does NOT silently fall back + // to byte-relaying through a third party. + if !self.network.conn_handle().is_session_relay_enabled().await { + debug!(target = hex::encode(peer_id), "Session relay opt-out — skipping relay fallback"); + } else if let (Some(intro_id), Some(relay_peer)) = (last_intro_id, last_relay_peer) { if last_relay_available { info!( target = hex::encode(peer_id), @@ -4577,44 +4583,16 @@ impl Node { }) } - /// Start UPnP lease renewal cycle. Renews every lease_secs/2. - /// On 3 consecutive failures: clears is_anchor and logs a warning. + /// No-op since v0.7.2: the `portmapper::Client` held inside the active + /// `PortMapping` auto-renews internally in its own background service. + /// Retained for API compatibility with callers in CLI and Tauri until + /// they're cleaned up. Returns `None`. + /// + /// TODO(v0.7.x): wire a watcher on `mapping.watch_external()` to clear + /// anchor mode if the external address stays `None` for more than ~5min + /// (parity with the old "3 renewal failures" behavior). pub fn start_upnp_renewal_cycle(&self) -> Option> { - let mapping = self.network.upnp_mapping()?; - let local_port = mapping.local_port; - let external_port = mapping.external_addr.port(); - let interval_secs = (mapping.lease_secs / 2) as u64; - let network = Arc::clone(&self.network); - let alog = Arc::clone(&self.activity_log); - - Some(tokio::spawn(async move { - let mut interval = - tokio::time::interval(std::time::Duration::from_secs(interval_secs)); - let mut consecutive_failures: u32 = 0; - loop { - interval.tick().await; - if crate::upnp::renew_upnp_mapping(local_port, external_port).await { - consecutive_failures = 0; - debug!("UPnP: lease renewed (port {})", external_port); - } else { - consecutive_failures += 1; - warn!("UPnP: renewal failed ({}/3)", consecutive_failures); - if consecutive_failures >= 3 { - network.clear_anchor(); - if let Ok(mut log) = alog.try_lock() { - log.log( - ActivityLevel::Warn, - ActivityCategory::Connection, - "UPnP lease lost after 3 renewal failures, auto-anchor disabled".into(), - None, - ); - } - warn!("UPnP: 3 consecutive renewal failures, auto-anchor disabled"); - return; // stop the cycle - } - } - } - })) + None } // --- HTTP Post Delivery --- @@ -4669,34 +4647,21 @@ impl Node { }) } - /// Start UPnP TCP lease renewal cycle alongside the UDP renewal. + /// No-op since v0.7.2 — the TCP `portmapper::Client` auto-renews internally. pub fn start_upnp_tcp_renewal_cycle(&self) -> Option> { - if !self.network.has_upnp_tcp() { - return None; - } - let mapping = self.network.upnp_mapping()?; - let local_port = mapping.local_port; - let external_port = mapping.external_addr.port(); - let interval_secs = (mapping.lease_secs / 2) as u64; - - Some(tokio::spawn(async move { - let mut interval = - tokio::time::interval(std::time::Duration::from_secs(interval_secs)); - loop { - interval.tick().await; - if !crate::upnp::renew_upnp_tcp_mapping(local_port, external_port).await { - warn!("UPnP: TCP lease renewal failed"); - // Don't stop the cycle — TCP is best-effort - } - } - })) + None } /// Generate a share link URL for a public post. /// Returns None if post is not public or not found. + /// + /// URL Phase 1 (v0.7.2): the link contains only the post ID — no author + /// hex, no node addresses. The receiving anchor (itsgoin.net) does the + /// holder lookup itself and serves via redirect or QUIC-proxy fallback. + /// Older URLs with `/{post_hex}/{author_hex}` continue to work — the + /// web handler parses the author hex as optional. pub async fn generate_share_link(&self, post_id: &PostId) -> anyhow::Result> { - // Look up the post to verify it's public and get the author - let (post, visibility) = { + let (_post, visibility) = { let store = self.storage.get().await; match store.get_post_with_visibility(post_id)? { Some(pv) => pv, @@ -4709,8 +4674,7 @@ impl Node { } let post_hex = hex::encode(post_id); - let author_hex = hex::encode(post.author); - Ok(Some(format!("https://itsgoin.net/p/{}/{}", post_hex, author_hex))) + Ok(Some(format!("https://itsgoin.net/p/{}", post_hex))) } // --- Engagement API --- diff --git a/crates/core/src/upnp.rs b/crates/core/src/upnp.rs index df4f445..643a985 100644 --- a/crates/core/src/upnp.rs +++ b/crates/core/src/upnp.rs @@ -1,243 +1,181 @@ -//! Best-effort UPnP port mapping for NAT traversal. -//! Skipped entirely on mobile platforms where UPnP is unsupported. +//! NAT port mapping via UPnP-IGD, NAT-PMP, and PCP. +//! +//! Wraps the `portmapper` crate (also used internally by iroh) which runs +//! all three protocols in parallel and auto-renews the lease in a background +//! task. This module is named `upnp` for historical reasons — by v0.7.2 it +//! covers more than UPnP. +//! +//! ## Protocols +//! - **UPnP-IGD** — long-standing consumer-router default. Discovery uses +//! SSDP multicast on 239.255.255.250:1900. Behavior on Hold harbor routers +//! varies; many ship with UPnP disabled by default. +//! - **NAT-PMP** (RFC 6886) — Apple lineage; widespread on routers that +//! ever shipped Bonjour. Unicast to the gateway on UDP/5351. +//! - **PCP** (RFC 6887) — modern IETF-track successor to NAT-PMP. Unicast +//! on UDP/5351. Supports both IPv4 NAT mapping and IPv6 firewall pinholes +//! (the latter via `AddPinhole`-shaped requests). Increasingly common in +//! modern routers. +//! +//! All three are attempted in parallel by portmapper; the first one to +//! respond wins. PCP responses arrive sub-second when present; SSDP wakes +//! up in 1–3s. +//! +//! ## Per-platform contract +//! | Platform | UPnP-IGD | NAT-PMP | PCP | TCP for HTTP | +//! |---|---|---|---|---| +//! | Linux/macOS/Windows | ✓ | ✓ | ✓ | ✓ | +//! | Android (WiFi/Ethernet) | ✓ (with MulticastLock) | ✓ | ✓ | ✓ | +//! | Android (cellular) | ✗ (skipped early) | ✗ | ✗ | ✗ | +//! | iOS | ✗ (without `com.apple.developer.networking.multicast` entitlement) | ✓ | ✓ | ✓ | +//! +//! ## Android specifics +//! `try_udp` / `try_tcp` first check `crate::android_wifi::is_on_wifi()`. +//! If not on WiFi/Ethernet (cellular), returns `None` immediately — cellular +//! networks almost never expose any of these protocols, and a discovery +//! timeout would waste ~3s every startup. +//! +//! When on WiFi, a `WifiManager.MulticastLock` is acquired and held for the +//! lifetime of the resulting `PortMapping`. Without the lock Android filters +//! the SSDP multicast responses; PCP/NAT-PMP work without it but UPnP-IGD +//! would never complete. The lock is released on Drop. +//! +//! ## iOS specifics +//! Until Apple grants the multicast entitlement, UPnP-IGD will silently fail +//! (no SSDP responses delivered to the socket). The unicast protocols PCP +//! and NAT-PMP succeed without any entitlement and cover most modern home +//! routers, so iOS gets reasonable coverage today. +//! +//! ## Renewal model +//! Auto-renewal happens inside `portmapper::Client`'s internal task — there +//! is **no** external renewal cycle to schedule. The `PortMapping` value +//! owns the client; while it's held, the mapping stays alive. Dropping it +//! aborts the renewal task (via `AbortOnDropHandle`) and stops keeping the +//! mapping alive at the gateway. `release(&self)` triggers an explicit +//! deactivation message before drop for cleaner shutdown. +//! +//! ## Anchor reachability watcher (bidirectional) +//! `Network::start` spawns a task that observes the UDP mapping's +//! `watch_external()` channel and adjusts anchor mode at runtime: +//! - **Mapping lost** for >5 min → clear `is_anchor`. The node stops +//! advertising itself as an anchor at the now-stale external address. +//! - **Mapping restored** (None → Some) → re-evaluate auto-anchor. On +//! non-mobile devices the anchor flag is set back on so the node +//! re-joins the anchor set without a restart. +//! +//! Network roams (e.g., leaving a UPnP-capable WiFi and joining a new one) +//! self-heal. Mobile devices never auto-anchor regardless — cellular IPs +//! look public but sit behind CGNAT. use std::net::SocketAddr; -#[cfg(not(any(target_os = "android", target_os = "ios")))] -use tracing::{info, debug}; +use std::num::NonZeroU16; +use std::time::Duration; +use tracing::{debug, info, warn}; -/// Result of a successful UPnP port mapping. -pub struct UpnpMapping { +/// An active port mapping. While this value is held, the underlying +/// `portmapper::Client` keeps the lease alive in a background task. +/// Dropping it releases the mapping. +pub struct PortMapping { pub external_addr: SocketAddr, - pub lease_secs: u32, pub local_port: u16, + #[allow(dead_code)] // service kept alive by holding the client + client: portmapper::Client, + #[cfg(target_os = "android")] + #[allow(dead_code)] // lock kept alive for the lifetime of the mapping + mcast_lock: Option, } -/// Best-effort UPnP port mapping. -/// 3s gateway discovery timeout, 1800s (30 min) lease, UDP protocol. -/// Returns None on any failure (no router, unsupported, timeout, port conflict). -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub async fn try_upnp_mapping(local_port: u16) -> Option { - use igd_next::SearchOptions; +impl PortMapping { + /// Best-effort UDP port mapping for the given local QUIC port. + /// On Android, requires an active WiFi/Ethernet connection — returns + /// `None` immediately on cellular. Waits up to 3 seconds for any of the + /// three protocols (PCP / NAT-PMP / UPnP-IGD) to return a mapping. + pub async fn try_udp(local_port: u16) -> Option { + Self::try_for_protocol(local_port, portmapper::Protocol::Udp).await + } - let search_opts = SearchOptions { - timeout: Some(std::time::Duration::from_secs(3)), - ..Default::default() - }; + /// Best-effort TCP port mapping for HTTP serving on the given local port. + /// Same platform gating as UDP. Used to make this node HTTP-reachable + /// for browser-shaped fetches of public posts. + pub async fn try_tcp(local_port: u16) -> Option { + Self::try_for_protocol(local_port, portmapper::Protocol::Tcp).await + } - let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await { - Ok(gw) => gw, - Err(e) => { - debug!("UPnP gateway discovery failed (expected behind non-UPnP router): {}", e); - return None; + async fn try_for_protocol(local_port: u16, protocol: portmapper::Protocol) -> Option { + let port = NonZeroU16::new(local_port)?; + + #[cfg(target_os = "android")] + { + if !crate::android_wifi::is_on_wifi() { + debug!("Port mapping: skipping (not on WiFi/Ethernet)"); + return None; + } } - }; - let external_ip = match gateway.get_external_ip().await { - Ok(ip) => ip, - Err(e) => { - debug!("UPnP: could not get external IP: {}", e); - return None; - } - }; + #[cfg(target_os = "android")] + let mcast_lock = crate::android_wifi::MulticastLockGuard::acquire("itsgoin-ssdp"); - // Local address for the mapping — bind to all interfaces - let local_addr = SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), local_port); - let lease_secs: u32 = 1800; // 30 minutes + let config = portmapper::Config { + enable_upnp: true, + enable_pcp: true, + enable_nat_pmp: true, + protocol, + }; + let client = portmapper::Client::new(config); + client.update_local_port(port); - // Try mapping the same external port first - let result = gateway.add_port( - igd_next::PortMappingProtocol::UDP, - local_port, - local_addr, - lease_secs, - "itsgoin", - ).await; - - let external_port = match result { - Ok(()) => local_port, - Err(_) => { - // Port taken — try any available port - match gateway.add_any_port( - igd_next::PortMappingProtocol::UDP, - local_addr, - lease_secs, - "itsgoin", - ).await { - Ok(port) => port, - Err(e) => { - debug!("UPnP: port mapping failed: {}", e); + // Wait up to 3 seconds for any protocol to produce a mapping. + let mut watch = client.watch_external_address(); + let external = match tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let Some(addr) = *watch.borrow_and_update() { + return Some(addr); + } + if watch.changed().await.is_err() { return None; } } - } - }; - - let external_addr = SocketAddr::new(external_ip, external_port); - info!("UPnP: mapped {}:{} → :{}", external_ip, external_port, local_port); - - Some(UpnpMapping { - external_addr, - lease_secs, - local_port, - }) -} - -#[cfg(any(target_os = "android", target_os = "ios"))] -pub async fn try_upnp_mapping(_local_port: u16) -> Option { - None -} - -/// Renew an existing UPnP lease. Returns true on success. -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub async fn renew_upnp_mapping(local_port: u16, external_port: u16) -> bool { - use igd_next::SearchOptions; - - let search_opts = SearchOptions { - timeout: Some(std::time::Duration::from_secs(3)), - ..Default::default() - }; - - let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await { - Ok(gw) => gw, - Err(_) => return false, - }; - - let local_addr = SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), local_port); - gateway.add_port( - igd_next::PortMappingProtocol::UDP, - external_port, - local_addr, - 1800, - "itsgoin", - ).await.is_ok() -} - -#[cfg(any(target_os = "android", target_os = "ios"))] -pub async fn renew_upnp_mapping(_local_port: u16, _external_port: u16) -> bool { - false -} - -/// Remove UPnP mapping on shutdown. Best-effort, errors are silently ignored. -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub async fn remove_upnp_mapping(external_port: u16) { - use igd_next::SearchOptions; - - let search_opts = SearchOptions { - timeout: Some(std::time::Duration::from_secs(3)), - ..Default::default() - }; - - if let Ok(gateway) = igd_next::aio::tokio::search_gateway(search_opts).await { - let _ = gateway.remove_port(igd_next::PortMappingProtocol::UDP, external_port).await; - info!("UPnP: removed port mapping for external port {}", external_port); - } -} - -#[cfg(any(target_os = "android", target_os = "ios"))] -pub async fn remove_upnp_mapping(_external_port: u16) {} - -// --- TCP port mapping (for HTTP post delivery) --- - -/// Best-effort UPnP TCP port mapping on the same port as QUIC UDP. -/// Returns true on success. Reuses the already-discovered gateway. -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub async fn try_upnp_tcp_mapping(local_port: u16, external_port: u16) -> bool { - use igd_next::SearchOptions; - - let search_opts = SearchOptions { - timeout: Some(std::time::Duration::from_secs(3)), - ..Default::default() - }; - - let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await { - Ok(gw) => gw, - Err(_) => return false, - }; - - let local_addr = SocketAddr::new( - std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), - local_port, - ); - - match gateway - .add_port( - igd_next::PortMappingProtocol::TCP, - external_port, - local_addr, - 1800, - "itsgoin-http", - ) + }) .await - { - Ok(()) => { - info!("UPnP: TCP port {} mapped for HTTP serving", external_port); - true - } - Err(e) => { - debug!("UPnP: TCP port mapping failed (non-fatal): {}", e); - false - } + { + Ok(Some(addr)) => addr, + _ => { + debug!( + protocol = ?protocol, + local_port, + "Port mapping: no protocol responded within 3s (no UPnP/PCP/NAT-PMP gateway?)" + ); + return None; + } + }; + + info!( + external = %external, + local_port, + protocol = ?protocol, + "Port mapping established" + ); + + Some(PortMapping { + external_addr: SocketAddr::V4(external), + local_port, + client, + #[cfg(target_os = "android")] + mcast_lock, + }) + } + + /// Watch for changes to the external address. Useful when the underlying + /// network changes (e.g., mobile WiFi roam) — the mapping may move to a + /// new public IP/port. + pub fn watch_external(&self) -> tokio::sync::watch::Receiver> { + self.client.watch_external_address() + } + + /// Explicitly request the portmapper service to release the gateway + /// mapping. The actual cleanup completes when the `PortMapping` is + /// dropped (the internal service handle is abort-on-drop). Allows + /// callers to signal "release now" before the value goes out of scope. + pub fn release(&self) { + self.client.deactivate(); } } - -#[cfg(any(target_os = "android", target_os = "ios"))] -pub async fn try_upnp_tcp_mapping(_local_port: u16, _external_port: u16) -> bool { - false -} - -/// Renew an existing UPnP TCP lease. Returns true on success. -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub async fn renew_upnp_tcp_mapping(local_port: u16, external_port: u16) -> bool { - use igd_next::SearchOptions; - - let search_opts = SearchOptions { - timeout: Some(std::time::Duration::from_secs(3)), - ..Default::default() - }; - - let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await { - Ok(gw) => gw, - Err(_) => return false, - }; - - let local_addr = SocketAddr::new( - std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), - local_port, - ); - gateway - .add_port( - igd_next::PortMappingProtocol::TCP, - external_port, - local_addr, - 1800, - "itsgoin-http", - ) - .await - .is_ok() -} - -#[cfg(any(target_os = "android", target_os = "ios"))] -pub async fn renew_upnp_tcp_mapping(_local_port: u16, _external_port: u16) -> bool { - false -} - -/// Remove UPnP TCP mapping on shutdown. -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub async fn remove_upnp_tcp_mapping(external_port: u16) { - use igd_next::SearchOptions; - - let search_opts = SearchOptions { - timeout: Some(std::time::Duration::from_secs(3)), - ..Default::default() - }; - - if let Ok(gateway) = igd_next::aio::tokio::search_gateway(search_opts).await { - let _ = gateway - .remove_port(igd_next::PortMappingProtocol::TCP, external_port) - .await; - info!("UPnP: removed TCP port mapping for port {}", external_port); - } -} - -#[cfg(any(target_os = "android", target_os = "ios"))] -pub async fn remove_upnp_tcp_mapping(_external_port: u16) {} diff --git a/crates/tauri-app/Cargo.toml b/crates/tauri-app/Cargo.toml index fbb8b23..7283ae1 100644 --- a/crates/tauri-app/Cargo.toml +++ b/crates/tauri-app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-desktop" -version = "0.7.1" +version = "0.7.2" edition = "2021" [lib] diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 6246241..a713ceb 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -1142,6 +1142,11 @@ async fn list_vouches_given(state: State<'_, AppNode>) -> Result) -> Result, String> { let node = get_node(&state).await; @@ -1698,6 +1703,25 @@ async fn set_update_channel(state: State<'_, AppNode>, channel: String) -> Resul storage.set_setting("ui_update_channel", &channel).map_err(|e| e.to_string()) } +#[tauri::command] +async fn get_session_relay_enabled(state: State<'_, AppNode>) -> Result { + let node = get_node(&state).await; + Ok(node.network.conn_handle().is_session_relay_enabled().await) +} + +#[tauri::command] +async fn set_session_relay_enabled(state: State<'_, AppNode>, enabled: bool) -> Result<(), String> { + let node = get_node(&state).await; + { + let storage = node.storage.get().await; + storage + .set_setting("relay.session_relay_enabled", if enabled { "true" } else { "false" }) + .map_err(|e| e.to_string())?; + } + node.network.conn_handle().set_session_relay_enabled(enabled).await; + Ok(()) +} + /// Open a URL in the user's default system browser. /// Desktop: spawns the platform opener (xdg-open / open / cmd start). /// Only https:// URLs are accepted to avoid being a generic command exec. @@ -3380,6 +3404,9 @@ pub fn run() { import_as_new_identity, import_as_personas_cmd, import_merge_with_key, + exit_app, + get_session_relay_enabled, + set_session_relay_enabled, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/crates/tauri-app/tauri.conf.json b/crates/tauri-app/tauri.conf.json index 96bdf58..4ec7623 100644 --- a/crates/tauri-app/tauri.conf.json +++ b/crates/tauri-app/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "itsgoin", - "version": "0.7.1", + "version": "0.7.2", "identifier": "com.itsgoin.app", "build": { "frontendDist": "../../frontend", diff --git a/frontend/app.js b/frontend/app.js index 5fc2a2b..7db32cb 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -449,6 +449,12 @@ $('#popover-overlay').addEventListener('click', (e) => { if (e.target === $('#popover-overlay')) closePopover(); }); +$('#close-app-btn').addEventListener('click', async () => { + if (confirm('Close ItsGoin?\n\nStops all network connections to save battery. Reopen the app any time to resume.')) { + try { await invoke('exit_app'); } catch (_) {} + } +}); + function relativeTime(timestampMs) { const now = Date.now(); const diff = now - timestampMs; @@ -4245,6 +4251,20 @@ $('#import-btn').addEventListener('click', () => { overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); }); +(async () => { + const toggle = $('#session-relay-toggle'); + if (!toggle) return; + try { toggle.checked = await invoke('get_session_relay_enabled'); } catch (_) {} + toggle.addEventListener('change', async () => { + try { + await invoke('set_session_relay_enabled', { enabled: toggle.checked }); + } catch (e) { + toggle.checked = !toggle.checked; + alert('Failed to update session relay setting: ' + e); + } + }); +})(); + $('#notifications-btn').addEventListener('click', async () => { // Load current settings const msgVal = await invoke('get_setting', { key: 'notif_messages' }).catch(() => null) || 'on'; diff --git a/frontend/index.html b/frontend/index.html index 7abd8f2..91a18c7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -25,6 +25,7 @@ +