feat: v0.7.2 — portmapper (UPnP+PCP+NAT-PMP), session-relay opt-in, URL Phase 1
Network/reachability improvements + a relay-privacy fix. Wire-compatible with v0.7.0/v0.7.1; no protocol changes. - Replace hand-rolled UPnP (igd-next) with the portmapper crate. All three protocols (UPnP-IGD / NAT-PMP / PCP) run in parallel, auto-renew internally. PCP adds IPv6 firewall pinholes and works on iOS without the multicast entitlement. Pulled in transitively via iroh already, no net dep growth. - Android: UPnP/PCP/NAT-PMP attempted on WiFi/Ethernet with a WifiManager.MulticastLock acquired for the lifetime of the mapping. Cellular skipped early (no UPnP/PCP gateway, avoid 3s discovery waste). - TCP port-mapping gate removed for mobile — phones with permissive NAT can now serve HTTP for direct browser fetches. - Anchor reachability watcher (bidirectional): clears is_anchor after >5min of no port mapping; restores it when the mapping comes back. Network roams self-heal without restart. Mobile never auto-anchors. - Session relay opt-in restored. relay.session_relay_enabled setting defaults OFF (anchors included — servers shouldn't silently burn bandwidth either). Gates both serving (can_accept_relay_pipe) and using (auto-fallback in node.rs). UI toggle in Settings. Relay-style signaling (RelayIntroduce / worm_lookup / N1-N3 shares) unaffected. - URL Phase 1: share links now contain only the post ID (itsgoin.net/p/<post>). Anchor handler already supported post-ID-only URLs (author was optional); just dropped the author hex from the generator. Older URLs with author hex continue to work. - Quick app close button in header (with confirm) — useful for stopping network activity between sessions on mobile. - JNI null-pointer guards on ndk_context handles in android_wifi.rs. MEMORY rule sharpened to distinguish session relay (byte pipe, opt-in) from relay-style signaling/discovery (always on). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
069257c2d8
commit
4706e81603
16 changed files with 696 additions and 340 deletions
10
Cargo.lock
generated
10
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "itsgoin-cli"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
215
crates/core/src/android_wifi.rs
Normal file
215
crates/core/src/android_wifi.rs
Normal file
|
|
@ -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<bool, String> {
|
||||
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<Self> {
|
||||
match Self::acquire_inner(tag) {
|
||||
Ok(g) => Some(g),
|
||||
Err(e) => {
|
||||
debug!("MulticastLock acquire failed: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn acquire_inner(tag: &str) -> Result<Self, String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -626,6 +626,11 @@ pub struct ConnectionManager {
|
|||
active_relay_pipes: Arc<AtomicU64>,
|
||||
/// 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<AtomicBool>,
|
||||
/// Device profile (for resource limits)
|
||||
#[allow(dead_code)]
|
||||
device_profile: DeviceProfile,
|
||||
|
|
@ -707,11 +712,13 @@ impl ConnectionManager {
|
|||
bind_addr: Option<SocketAddr>,
|
||||
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<bool>,
|
||||
},
|
||||
IsSessionRelayEnabled {
|
||||
reply: oneshot::Sender<bool>,
|
||||
},
|
||||
SetSessionRelayEnabled {
|
||||
enabled: bool,
|
||||
reply: oneshot::Sender<()>,
|
||||
},
|
||||
BuildAnchorAdvertisedAddr {
|
||||
reply: oneshot::Sender<Option<String>>,
|
||||
},
|
||||
|
|
@ -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<String> {
|
||||
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());
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
pub mod activity;
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod android_wifi;
|
||||
pub mod blob;
|
||||
pub mod connection;
|
||||
pub mod content;
|
||||
|
|
|
|||
|
|
@ -35,10 +35,12 @@ pub struct Network {
|
|||
/// Growth loop signal sender (set by start_growth_loop)
|
||||
growth_tx: tokio::sync::Mutex<Option<tokio::sync::mpsc::Sender<()>>>,
|
||||
activity_log: Arc<std::sync::Mutex<ActivityLog>>,
|
||||
/// UPnP mapping result (None if no mapping or on mobile)
|
||||
upnp_mapping: Option<crate::upnp::UpnpMapping>,
|
||||
/// 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<crate::upnp::PortMapping>,
|
||||
/// TCP port mapping for HTTP serving, if available. Holding it keeps
|
||||
/// the lease renewed by the portmapper background service.
|
||||
tcp_mapping: Option<crate::upnp::PortMapping>,
|
||||
/// 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<Mutex<CM>> (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<std::time::Instant> = 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<String> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<tokio::task::JoinHandle<()>> {
|
||||
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<tokio::task::JoinHandle<()>> {
|
||||
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<Option<String>> {
|
||||
// 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 ---
|
||||
|
|
|
|||
|
|
@ -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<crate::android_wifi::MulticastLockGuard>,
|
||||
}
|
||||
|
||||
/// 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<UpnpMapping> {
|
||||
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> {
|
||||
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> {
|
||||
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);
|
||||
async fn try_for_protocol(local_port: u16, protocol: portmapper::Protocol) -> Option<Self> {
|
||||
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);
|
||||
#[cfg(target_os = "android")]
|
||||
let mcast_lock = crate::android_wifi::MulticastLockGuard::acquire("itsgoin-ssdp");
|
||||
|
||||
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);
|
||||
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
|
||||
// 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
|
||||
|
||||
// Try mapping the same external port first
|
||||
let result = gateway.add_port(
|
||||
igd_next::PortMappingProtocol::UDP,
|
||||
}
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Some(addr)) => addr,
|
||||
_ => {
|
||||
debug!(
|
||||
protocol = ?protocol,
|
||||
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);
|
||||
"Port mapping: no protocol responded within 3s (no UPnP/PCP/NAT-PMP gateway?)"
|
||||
);
|
||||
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,
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
pub async fn try_upnp_mapping(_local_port: u16) -> Option<UpnpMapping> {
|
||||
None
|
||||
/// 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<Option<std::net::SocketAddrV4>> {
|
||||
self.client.watch_external_address()
|
||||
}
|
||||
|
||||
/// 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);
|
||||
/// 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 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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) {}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "itsgoin-desktop"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
|
|
|||
|
|
@ -1142,6 +1142,11 @@ async fn list_vouches_given(state: State<'_, AppNode>) -> Result<Vec<VouchGivenD
|
|||
}).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn exit_app(app: tauri::AppHandle) {
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_vouches_received(state: State<'_, AppNode>) -> Result<Vec<VouchReceivedDto>, 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<bool, String> {
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"productName": "itsgoin",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.2",
|
||||
"identifier": "com.itsgoin.app",
|
||||
"build": {
|
||||
"frontendDist": "../../frontend",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
<span id="net-dot"></span>
|
||||
<span id="net-labels"></span>
|
||||
</div>
|
||||
<button id="close-app-btn" title="Close app (stops connections to save battery)" aria-label="Close app">⏻</button>
|
||||
</div>
|
||||
<nav id="tabs">
|
||||
<button class="tab" data-tab="feed"><span class="tab-icon">📰</span><span class="tab-label">Feed</span></button>
|
||||
|
|
@ -274,6 +275,17 @@
|
|||
<button id="notifications-btn" class="btn btn-ghost btn-full">Notifications</button>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<h3 style="margin-bottom:0.4rem;font-size:0.85rem;color:#888">Session Relay (off by default)</h3>
|
||||
<p class="empty-hint" style="margin-bottom:0.5rem;font-size:0.72rem">
|
||||
When two peers can't connect directly through their networks, a third peer can pipe their traffic through itself. This burns the relay peer's bandwidth on someone else's connection. Off by default. Enable only if you're OK both <em>using</em> other peers as relays and <em>serving</em> as a relay for others.
|
||||
</p>
|
||||
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer">
|
||||
<input type="checkbox" id="session-relay-toggle">
|
||||
<span>Enable session relay</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Hidden: node-info, anchors, diagnostics btn moved elsewhere -->
|
||||
<div class="hidden">
|
||||
<button id="anchors-toggle"></button>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ header h1 { font-size: clamp(1.4rem, 2.5vw, 2rem); color: #7fdbca; margin: 0; }
|
|||
#net-dot.net-green { background: #22c55e; }
|
||||
#net-labels { font-size: 0.65rem; color: #888; display: flex; gap: 0.3rem; }
|
||||
.net-label { background: #2a2a40; padding: 0.1rem 0.35rem; border-radius: 3px; color: #aab; }
|
||||
#close-app-btn { margin-left: 0.6rem; padding: 0.3rem 0.5rem; background: transparent; border: 1px solid #444; border-radius: 4px; color: #999; font-size: 1rem; line-height: 1; cursor: pointer; }
|
||||
#close-app-btn:hover { color: #e74c3c; border-color: #e74c3c; }
|
||||
#close-app-btn:active { background: #2a1a1a; }
|
||||
|
||||
/* Setup overlay */
|
||||
.overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(10, 10, 20, 0.92); display: flex; align-items: center; justify-content: center; z-index: 200; }
|
||||
|
|
|
|||
|
|
@ -26,25 +26,38 @@
|
|||
<h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 0.25rem;">Download ItsGoin</h1>
|
||||
<p>Available for Android, Linux, and Windows. Free and open source.</p>
|
||||
|
||||
<div class="note" style="margin-top: 1rem; border-left: 3px solid var(--accent); padding-left: 0.9rem;">
|
||||
<strong>Upgrading from v0.5.1 or newer?</strong> v0.6 is a hard network fork — older versions can no longer reach the network. Bring your account over in three steps:
|
||||
<ol style="margin: 0.5rem 0 0.25rem 1.25rem; padding: 0;">
|
||||
<li>On your current v0.5 install, go to <strong>Settings → Export</strong>, tick <em>“Posts + media + identity key”</em>, and save the .zip somewhere safe.</li>
|
||||
<li>Download v0.6 below (APK, AppImage, or Windows installer).</li>
|
||||
<li>On first launch, choose <strong>“Import an Identity”</strong> and point it at the .zip.</li>
|
||||
</ol>
|
||||
<p style="margin: 0.5rem 0 0 0; font-size: 0.8rem; color: var(--text-muted);">Your posts, media, follows, and identity key all come across. Encrypted posts stay decryptable under the same key.</p>
|
||||
<h2 style="margin-top: 2rem;">v0.7.2 — May 15, 2026</h2>
|
||||
<p style="color: var(--text-muted); font-size: 0.85rem;">Network & reachability improvements, plus a relay-privacy fix.</p>
|
||||
|
||||
<div class="downloads">
|
||||
<a href="itsgoin-0.7.2.apk" class="download-btn btn-android">
|
||||
Android APK
|
||||
<span class="sub">v0.7.2</span>
|
||||
</a>
|
||||
<a href="itsgoin_0.7.2_amd64.AppImage" class="download-btn btn-linux">
|
||||
Linux AppImage
|
||||
<span class="sub">v0.7.2</span>
|
||||
</a>
|
||||
<a href="itsgoin-cli-0.7.2-linux-amd64" class="download-btn btn-linux">
|
||||
Linux CLI / Anchor
|
||||
<span class="sub">v0.7.2</span>
|
||||
</a>
|
||||
<a href="itsgoin-0.7.2-windows-x64-setup.exe" class="download-btn btn-windows">
|
||||
Windows Installer
|
||||
<span class="sub">v0.7.2</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="note" style="margin-top: 0.75rem; border-left: 3px solid var(--text-muted); padding-left: 0.9rem;">
|
||||
<strong>Upgrading from v0.5.0 or older?</strong> The v0.5 export format matured in v0.5.1, so a direct export from 0.5.0 won't import cleanly into v0.6. Do a two-hop upgrade:
|
||||
<ol style="margin: 0.5rem 0 0.25rem 1.25rem; padding: 0;">
|
||||
<li>Install <a href="itsgoin_0.5.3_amd64.AppImage">v0.5.3 Linux AppImage</a> / <a href="itsgoin-0.5.3.apk">Android APK</a> / <a href="itsgoin-0.5.3-windows-x64-setup.exe">Windows installer</a> and open it. It reads your existing data in place.</li>
|
||||
<li>In v0.5.3, go to <strong>Settings → Export</strong> and save the bundle.</li>
|
||||
<li>Install v0.6.0 below and import that bundle on first launch.</li>
|
||||
</ol>
|
||||
<p style="margin: 0.5rem 0 0 0; font-size: 0.8rem; color: var(--text-muted);">v0.5.3 is kept online only as an upgrade bridge — it no longer connects to the live network.</p>
|
||||
</div>
|
||||
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 1rem;">
|
||||
<li><strong>NAT traversal: UPnP + NAT-PMP + PCP.</strong> Replaced UPnP-only port mapping with the <code>portmapper</code> crate (also used by iroh internally). All three protocols run in parallel; the first router-response wins. PCP adds IPv6 firewall pinholes and works on iOS without a multicast entitlement. Auto-renewal runs in a background task — no more lease-expiry surprises.</li>
|
||||
<li><strong>Android NAT mapping on WiFi.</strong> Mobile devices on WiFi now attempt UPnP/PCP/NAT-PMP just like desktops, with the required <code>WifiManager.MulticastLock</code> acquired for the lifetime of the mapping. Cellular is gated off (no UPnP/PCP gateway on carrier nets, so we skip the discovery cost). Reachability for phones-on-home-WiFi should improve noticeably.</li>
|
||||
<li><strong>Mobile HTTP serving when reachable.</strong> Phones whose router cooperates with TCP port mapping can now serve <code>/p/<post></code> directly for browser fetches. No mode switch — this just works when the network allows it.</li>
|
||||
<li><strong>Anchor-mode self-heals across network changes.</strong> A new watcher observes the live port mapping. Mapping lost >5min → anchor mode clears (don't keep advertising an unreachable address). Mapping restored → anchor mode comes back on, no restart needed. Roaming between UPnP-capable WiFi networks no longer requires a reboot.</li>
|
||||
<li><strong>Shorter share URLs.</strong> Share links now contain only the post ID — <code>itsgoin.net/p/<post></code>. The anchor handles holder lookup itself, so URLs stay stable as the holder set changes. Older URLs with the author hex appended continue to work.</li>
|
||||
<li><strong>Session relay is opt-in, off by default.</strong> Fixed a regression where hole-punch failures could silently pipe a peer-to-peer session through an intermediary's bandwidth. The previous behavior burned random users' bandwidth without consent. A new Settings toggle (<em>Enable session relay</em>) opts in to both serving as a relay and using one. Anchors default-off too — server bandwidth shouldn't be silently consumed either. Hole-punch coordination (small signaling) is unaffected.</li>
|
||||
<li><strong>Quick app close from the header.</strong> Power icon next to the network indicator fully terminates the app (with confirm). Useful on mobile to stop network activity between active sessions.</li>
|
||||
</ul>
|
||||
<p style="color: var(--text-muted); font-size: 0.85rem;">v0.7.2 is wire-compatible with v0.7.0 and v0.7.1. No protocol changes; all network & UX improvements are local.</p>
|
||||
|
||||
<h2 style="margin-top: 2rem;">v0.7.1 — May 15, 2026</h2>
|
||||
<p style="color: var(--text-muted); font-size: 0.85rem;">UI polish + bug-fix pass on top of v0.7.0's FoF gating. Default post visibility is now Extended Friends (FoF). New <em>Friend</em> button combines follow + vouch in one click. Network Identity renamed to Device Address (you almost never need to touch it). Settings clearly separates personas from device address with an export/import "Move to another device" flow. Plus three fixes: profile display name now updates everywhere when changed; redundancy panel reads from the correct author set so it no longer shows 0 for all posts; My Posts tab no longer horizontally overflows and breaks the sticky header/tabs.</p>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue