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:
Scott Reimers 2026-05-15 11:03:39 -06:00
parent 069257c2d8
commit 4706e81603
16 changed files with 696 additions and 340 deletions

10
Cargo.lock generated
View file

@ -2732,7 +2732,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]] [[package]]
name = "itsgoin-cli" name = "itsgoin-cli"
version = "0.7.1" version = "0.7.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"hex", "hex",
@ -2744,7 +2744,7 @@ dependencies = [
[[package]] [[package]]
name = "itsgoin-core" name = "itsgoin-core"
version = "0.7.1" version = "0.7.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1", "base64 0.22.1",
@ -2753,8 +2753,10 @@ dependencies = [
"curve25519-dalek", "curve25519-dalek",
"ed25519-dalek", "ed25519-dalek",
"hex", "hex",
"igd-next",
"iroh", "iroh",
"jni",
"ndk-context",
"portmapper",
"rand 0.9.2", "rand 0.9.2",
"rusqlite", "rusqlite",
"serde", "serde",
@ -2767,7 +2769,7 @@ dependencies = [
[[package]] [[package]]
name = "itsgoin-desktop" name = "itsgoin-desktop"
version = "0.7.1" version = "0.7.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1", "base64 0.22.1",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "itsgoin-cli" name = "itsgoin-cli"
version = "0.7.1" version = "0.7.2"
edition = "2021" edition = "2021"
[[bin]] [[bin]]

View file

@ -1,6 +1,6 @@
[package] [package]
name = "itsgoin-core" name = "itsgoin-core"
version = "0.7.1" version = "0.7.2"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
@ -19,7 +19,11 @@ ed25519-dalek = { version = "=3.0.0-pre.1", features = ["rand_core", "zeroize"]
chacha20poly1305 = "0.10" chacha20poly1305 = "0.10"
base64 = "0.22" base64 = "0.22"
zip = { version = "2", default-features = false, features = ["deflate"] } 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] [dev-dependencies]
tempfile = "3" tempfile = "3"

View 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);
}
}
}

View file

@ -626,6 +626,11 @@ pub struct ConnectionManager {
active_relay_pipes: Arc<AtomicU64>, active_relay_pipes: Arc<AtomicU64>,
/// Max concurrent relay pipes /// Max concurrent relay pipes
max_relay_pipes: usize, 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) /// Device profile (for resource limits)
#[allow(dead_code)] #[allow(dead_code)]
device_profile: DeviceProfile, device_profile: DeviceProfile,
@ -707,11 +712,13 @@ impl ConnectionManager {
bind_addr: Option<SocketAddr>, bind_addr: Option<SocketAddr>,
nat_type: crate::types::NatType, nat_type: crate::types::NatType,
nat_mapping: crate::types::NatMapping, nat_mapping: crate::types::NatMapping,
session_relay_enabled: bool,
) -> Self { ) -> Self {
let now = std::time::SystemTime::now() let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
.as_millis() as u64; .as_millis() as u64;
let session_relay_enabled = Arc::new(AtomicBool::new(session_relay_enabled));
Self { Self {
connections: HashMap::new(), connections: HashMap::new(),
endpoint, endpoint,
@ -732,6 +739,7 @@ impl ConnectionManager {
seen_intros: HashMap::new(), seen_intros: HashMap::new(),
active_relay_pipes: Arc::new(AtomicU64::new(0)), active_relay_pipes: Arc::new(AtomicU64::new(0)),
max_relay_pipes: profile.max_relay_pipes(), max_relay_pipes: profile.max_relay_pipes(),
session_relay_enabled,
device_profile: profile, device_profile: profile,
unreachable_peers: HashMap::new(), unreachable_peers: HashMap::new(),
referral_list: HashMap::new(), referral_list: HashMap::new(),
@ -3631,11 +3639,28 @@ impl ConnectionManager {
&self.active_relay_pipes &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 { 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 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. /// Get our node ID.
pub fn our_node_id(&self) -> &NodeId { pub fn our_node_id(&self) -> &NodeId {
&self.our_node_id &self.our_node_id
@ -6581,6 +6606,13 @@ pub enum ConnCommand {
CanAcceptRelayPipe { CanAcceptRelayPipe {
reply: oneshot::Sender<bool>, reply: oneshot::Sender<bool>,
}, },
IsSessionRelayEnabled {
reply: oneshot::Sender<bool>,
},
SetSessionRelayEnabled {
enabled: bool,
reply: oneshot::Sender<()>,
},
BuildAnchorAdvertisedAddr { BuildAnchorAdvertisedAddr {
reply: oneshot::Sender<Option<String>>, reply: oneshot::Sender<Option<String>>,
}, },
@ -6978,6 +7010,18 @@ impl ConnHandle {
rx.await.unwrap_or(false) 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> { pub async fn build_anchor_advertised_addr(&self) -> Option<String> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
let _ = self.tx.send(ConnCommand::BuildAnchorAdvertisedAddr { reply: tx }).await; let _ = self.tx.send(ConnCommand::BuildAnchorAdvertisedAddr { reply: tx }).await;
@ -7902,6 +7946,15 @@ impl ConnectionActor {
let cm = self.cm.lock().await; let cm = self.cm.lock().await;
let _ = reply.send(cm.can_accept_relay_pipe()); 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 } => { ConnCommand::BuildAnchorAdvertisedAddr { reply } => {
let cm = self.cm.lock().await; let cm = self.cm.lock().await;
let _ = reply.send(cm.build_anchor_advertised_addr()); let _ = reply.send(cm.build_anchor_advertised_addr());

View file

@ -1,4 +1,6 @@
pub mod activity; pub mod activity;
#[cfg(target_os = "android")]
pub mod android_wifi;
pub mod blob; pub mod blob;
pub mod connection; pub mod connection;
pub mod content; pub mod content;

View file

@ -35,10 +35,12 @@ pub struct Network {
/// Growth loop signal sender (set by start_growth_loop) /// Growth loop signal sender (set by start_growth_loop)
growth_tx: tokio::sync::Mutex<Option<tokio::sync::mpsc::Sender<()>>>, growth_tx: tokio::sync::Mutex<Option<tokio::sync::mpsc::Sender<()>>>,
activity_log: Arc<std::sync::Mutex<ActivityLog>>, activity_log: Arc<std::sync::Mutex<ActivityLog>>,
/// UPnP mapping result (None if no mapping or on mobile) /// UDP port mapping (UPnP/NAT-PMP/PCP) for the QUIC socket.
upnp_mapping: Option<crate::upnp::UpnpMapping>, /// Holding this keeps the auto-renewing portmapper service alive.
/// Whether UPnP TCP mapping succeeded (for HTTP serving) upnp_mapping: Option<crate::upnp::PortMapping>,
has_upnp_tcp: bool, /// 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 /// Whether this node has a public IPv6 address
has_public_v6: bool, has_public_v6: bool,
/// Stable bind address (from --bind flag), passed to ConnectionManager for anchor advertised address /// 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(); 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 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() let bound_port = endpoint.bound_sockets().first()
.map(|s| s.port()).unwrap_or(0); .map(|s| s.port()).unwrap_or(0);
crate::upnp::try_upnp_mapping(bound_port).await crate::upnp::PortMapping::try_udp(bound_port).await
} else { } else {
None None
}; };
@ -215,6 +223,14 @@ impl Network {
} }
let upnp_external_addr = upnp_mapping.as_ref().map(|m| m.external_addr); 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( let conn_mgr = ConnectionManager::new(
endpoint.clone(), endpoint.clone(),
Arc::clone(&storage), Arc::clone(&storage),
@ -228,18 +244,26 @@ impl Network {
bind_addr, bind_addr,
nat_type, nat_type,
nat_mapping, nat_mapping,
session_relay_enabled,
); );
let conn_mgr = Arc::new(Mutex::new(conn_mgr)); let conn_mgr = Arc::new(Mutex::new(conn_mgr));
// Spawn actor wrapping the same Arc<Mutex<CM>> (Phase 1: additive) // Spawn actor wrapping the same Arc<Mutex<CM>> (Phase 1: additive)
let conn_handle = ConnectionActor::spawn_with_arc(Arc::clone(&conn_mgr)).await; let conn_handle = ConnectionActor::spawn_with_arc(Arc::clone(&conn_mgr)).await;
// TCP UPnP mapping for HTTP post delivery (only if UDP UPnP succeeded) // TCP port mapping for HTTP post delivery. Run on every platform —
let has_upnp_tcp = if let Some(ref mapping) = upnp_mapping { // mobile devices with permissive NAT (UPnP/PCP-TCP working) can serve
crate::upnp::try_upnp_tcp_mapping(mapping.local_port, mapping.external_addr.port()).await // 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 { } else {
false None
}; };
let has_upnp_tcp = tcp_mapping.is_some();
info!( info!(
node_id = %endpoint.id(), node_id = %endpoint.id(),
@ -259,6 +283,79 @@ impl Network {
info!(role = %device_role, "CDN replication role determined"); info!(role = %device_role, "CDN replication role determined");
conn_handle.set_device_role(device_role); 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 { Ok(Self {
endpoint, endpoint,
storage, storage,
@ -269,7 +366,7 @@ impl Network {
growth_tx: tokio::sync::Mutex::new(None), growth_tx: tokio::sync::Mutex::new(None),
activity_log, activity_log,
upnp_mapping, upnp_mapping,
has_upnp_tcp, tcp_mapping,
has_public_v6, has_public_v6,
bind_addr, bind_addr,
device_role, device_role,
@ -333,8 +430,8 @@ impl Network {
addrs addrs
} }
/// Get the UPnP mapping, if one was successfully acquired. /// Get the active UDP port mapping (UPnP/NAT-PMP/PCP), if any.
pub fn upnp_mapping(&self) -> Option<&crate::upnp::UpnpMapping> { pub fn upnp_mapping(&self) -> Option<&crate::upnp::PortMapping> {
self.upnp_mapping.as_ref() self.upnp_mapping.as_ref()
} }
@ -359,7 +456,7 @@ impl Network {
/// Whether this node can serve HTTP (has TCP reachability). /// Whether this node can serve HTTP (has TCP reachability).
pub fn is_http_capable(&self) -> bool { 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). /// 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 { pub fn has_upnp_tcp(&self) -> bool {
self.has_upnp_tcp self.tcp_mapping.is_some()
} }
/// Whether this node has a public IPv6 address. /// Whether this node has a public IPv6 address.
@ -383,15 +480,19 @@ impl Network {
self.has_public_v6 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 { 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. /// Get external HTTP address string for InitialExchange advertisement.
pub fn http_addr(&self) -> Option<String> { 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()); return Some(mapping.external_addr.to_string());
} }
if let Some(bind) = self.bind_addr { if let Some(bind) = self.bind_addr {
@ -2152,18 +2253,20 @@ impl Network {
pub async fn shutdown(self) -> anyhow::Result<()> { pub async fn shutdown(self) -> anyhow::Result<()> {
// Remove UPnP port mapping before closing endpoint // Remove UPnP port mapping before closing endpoint
if let Some(ref mapping) = self.upnp_mapping { // Dropping the PortMapping triggers the portmapper Client's
crate::upnp::remove_upnp_mapping(mapping.external_addr.port()).await; // 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; self.endpoint.close().await;
Ok(()) Ok(())
} }
/// Shutdown via Arc reference — closes the endpoint, causing all background tasks to exit. /// Shutdown via Arc reference — closes the endpoint, causing all background tasks to exit.
pub async fn shutdown_ref(&self) { pub async fn shutdown_ref(&self) {
if let Some(ref mapping) = self.upnp_mapping { // Dropping the PortMapping triggers the portmapper Client's
crate::upnp::remove_upnp_mapping(mapping.external_addr.port()).await; // 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; self.endpoint.close().await;
} }

View file

@ -3692,8 +3692,14 @@ impl Node {
} }
} }
// Step 7: Session relay fallback — if intro was accepted but hole punch failed // Step 7: Session relay fallback — only if BOTH the introducer
if let (Some(intro_id), Some(relay_peer)) = (last_intro_id, last_relay_peer) { // 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 { if last_relay_available {
info!( info!(
target = hex::encode(peer_id), target = hex::encode(peer_id),
@ -4577,44 +4583,16 @@ impl Node {
}) })
} }
/// Start UPnP lease renewal cycle. Renews every lease_secs/2. /// No-op since v0.7.2: the `portmapper::Client` held inside the active
/// On 3 consecutive failures: clears is_anchor and logs a warning. /// `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<()>> { pub fn start_upnp_renewal_cycle(&self) -> Option<tokio::task::JoinHandle<()>> {
let mapping = self.network.upnp_mapping()?; None
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
}
}
}
}))
} }
// --- HTTP Post Delivery --- // --- 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<()>> { pub fn start_upnp_tcp_renewal_cycle(&self) -> Option<tokio::task::JoinHandle<()>> {
if !self.network.has_upnp_tcp() { None
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
}
}
}))
} }
/// Generate a share link URL for a public post. /// Generate a share link URL for a public post.
/// Returns None if post is not public or not found. /// 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>> { 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; let store = self.storage.get().await;
match store.get_post_with_visibility(post_id)? { match store.get_post_with_visibility(post_id)? {
Some(pv) => pv, Some(pv) => pv,
@ -4709,8 +4674,7 @@ impl Node {
} }
let post_hex = hex::encode(post_id); let post_hex = hex::encode(post_id);
let author_hex = hex::encode(post.author); Ok(Some(format!("https://itsgoin.net/p/{}", post_hex)))
Ok(Some(format!("https://itsgoin.net/p/{}/{}", post_hex, author_hex)))
} }
// --- Engagement API --- // --- Engagement API ---

View file

@ -1,243 +1,181 @@
//! Best-effort UPnP port mapping for NAT traversal. //! NAT port mapping via UPnP-IGD, NAT-PMP, and PCP.
//! Skipped entirely on mobile platforms where UPnP is unsupported. //!
//! 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 13s.
//!
//! ## 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; use std::net::SocketAddr;
#[cfg(not(any(target_os = "android", target_os = "ios")))] use std::num::NonZeroU16;
use tracing::{info, debug}; use std::time::Duration;
use tracing::{debug, info, warn};
/// Result of a successful UPnP port mapping. /// An active port mapping. While this value is held, the underlying
pub struct UpnpMapping { /// `portmapper::Client` keeps the lease alive in a background task.
/// Dropping it releases the mapping.
pub struct PortMapping {
pub external_addr: SocketAddr, pub external_addr: SocketAddr,
pub lease_secs: u32,
pub local_port: u16, 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. impl PortMapping {
/// 3s gateway discovery timeout, 1800s (30 min) lease, UDP protocol. /// Best-effort UDP port mapping for the given local QUIC port.
/// Returns None on any failure (no router, unsupported, timeout, port conflict). /// On Android, requires an active WiFi/Ethernet connection — returns
#[cfg(not(any(target_os = "android", target_os = "ios")))] /// `None` immediately on cellular. Waits up to 3 seconds for any of the
pub async fn try_upnp_mapping(local_port: u16) -> Option<UpnpMapping> { /// three protocols (PCP / NAT-PMP / UPnP-IGD) to return a mapping.
use igd_next::SearchOptions; pub async fn try_udp(local_port: u16) -> Option<Self> {
Self::try_for_protocol(local_port, portmapper::Protocol::Udp).await
}
let search_opts = SearchOptions { /// Best-effort TCP port mapping for HTTP serving on the given local port.
timeout: Some(std::time::Duration::from_secs(3)), /// Same platform gating as UDP. Used to make this node HTTP-reachable
..Default::default() /// 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 { async fn try_for_protocol(local_port: u16, protocol: portmapper::Protocol) -> Option<Self> {
Ok(gw) => gw, let port = NonZeroU16::new(local_port)?;
Err(e) => {
debug!("UPnP gateway discovery failed (expected behind non-UPnP router): {}", e); #[cfg(target_os = "android")]
return None; {
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 { #[cfg(target_os = "android")]
Ok(ip) => ip, let mcast_lock = crate::android_wifi::MulticastLockGuard::acquire("itsgoin-ssdp");
Err(e) => {
debug!("UPnP: could not get external IP: {}", e);
return None;
}
};
// Local address for the mapping — bind to all interfaces let config = portmapper::Config {
let local_addr = SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), local_port); enable_upnp: true,
let lease_secs: u32 = 1800; // 30 minutes 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 // Wait up to 3 seconds for any protocol to produce a mapping.
let result = gateway.add_port( let mut watch = client.watch_external_address();
igd_next::PortMappingProtocol::UDP, let external = match tokio::time::timeout(Duration::from_secs(3), async {
local_port, loop {
local_addr, if let Some(addr) = *watch.borrow_and_update() {
lease_secs, return Some(addr);
"itsgoin", }
).await; if watch.changed().await.is_err() {
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);
return None; 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<UpnpMapping> {
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 .await
{ {
Ok(()) => { Ok(Some(addr)) => addr,
info!("UPnP: TCP port {} mapped for HTTP serving", external_port); _ => {
true debug!(
} protocol = ?protocol,
Err(e) => { local_port,
debug!("UPnP: TCP port mapping failed (non-fatal): {}", e); "Port mapping: no protocol responded within 3s (no UPnP/PCP/NAT-PMP gateway?)"
false );
} 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<Option<std::net::SocketAddrV4>> {
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) {}

View file

@ -1,6 +1,6 @@
[package] [package]
name = "itsgoin-desktop" name = "itsgoin-desktop"
version = "0.7.1" version = "0.7.2"
edition = "2021" edition = "2021"
[lib] [lib]

View file

@ -1142,6 +1142,11 @@ async fn list_vouches_given(state: State<'_, AppNode>) -> Result<Vec<VouchGivenD
}).collect()) }).collect())
} }
#[tauri::command]
async fn exit_app(app: tauri::AppHandle) {
app.exit(0);
}
#[tauri::command] #[tauri::command]
async fn list_vouches_received(state: State<'_, AppNode>) -> Result<Vec<VouchReceivedDto>, String> { async fn list_vouches_received(state: State<'_, AppNode>) -> Result<Vec<VouchReceivedDto>, String> {
let node = get_node(&state).await; 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()) 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. /// Open a URL in the user's default system browser.
/// Desktop: spawns the platform opener (xdg-open / open / cmd start). /// Desktop: spawns the platform opener (xdg-open / open / cmd start).
/// Only https:// URLs are accepted to avoid being a generic command exec. /// 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_new_identity,
import_as_personas_cmd, import_as_personas_cmd,
import_merge_with_key, import_merge_with_key,
exit_app,
get_session_relay_enabled,
set_session_relay_enabled,
]) ])
.build(tauri::generate_context!()) .build(tauri::generate_context!())
.expect("error while building tauri application") .expect("error while building tauri application")

View file

@ -1,6 +1,6 @@
{ {
"productName": "itsgoin", "productName": "itsgoin",
"version": "0.7.1", "version": "0.7.2",
"identifier": "com.itsgoin.app", "identifier": "com.itsgoin.app",
"build": { "build": {
"frontendDist": "../../frontend", "frontendDist": "../../frontend",

View file

@ -449,6 +449,12 @@ $('#popover-overlay').addEventListener('click', (e) => {
if (e.target === $('#popover-overlay')) closePopover(); 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) { function relativeTime(timestampMs) {
const now = Date.now(); const now = Date.now();
const diff = now - timestampMs; const diff = now - timestampMs;
@ -4245,6 +4251,20 @@ $('#import-btn').addEventListener('click', () => {
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 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 () => { $('#notifications-btn').addEventListener('click', async () => {
// Load current settings // Load current settings
const msgVal = await invoke('get_setting', { key: 'notif_messages' }).catch(() => null) || 'on'; const msgVal = await invoke('get_setting', { key: 'notif_messages' }).catch(() => null) || 'on';

View file

@ -25,6 +25,7 @@
<span id="net-dot"></span> <span id="net-dot"></span>
<span id="net-labels"></span> <span id="net-labels"></span>
</div> </div>
<button id="close-app-btn" title="Close app (stops connections to save battery)" aria-label="Close app">&#x23FB;</button>
</div> </div>
<nav id="tabs"> <nav id="tabs">
<button class="tab" data-tab="feed"><span class="tab-icon">&#x1f4f0;</span><span class="tab-label">Feed</span></button> <button class="tab" data-tab="feed"><span class="tab-icon">&#x1f4f0;</span><span class="tab-label">Feed</span></button>
@ -274,6 +275,17 @@
<button id="notifications-btn" class="btn btn-ghost btn-full">Notifications</button> <button id="notifications-btn" class="btn btn-ghost btn-full">Notifications</button>
</div> </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 --> <!-- Hidden: node-info, anchors, diagnostics btn moved elsewhere -->
<div class="hidden"> <div class="hidden">
<button id="anchors-toggle"></button> <button id="anchors-toggle"></button>

View file

@ -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-dot.net-green { background: #22c55e; }
#net-labels { font-size: 0.65rem; color: #888; display: flex; gap: 0.3rem; } #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; } .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 */ /* 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; } .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; }

View file

@ -26,25 +26,38 @@
<h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 0.25rem;">Download ItsGoin</h1> <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> <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;"> <h2 style="margin-top: 2rem;">v0.7.2 &mdash; May 15, 2026</h2>
<strong>Upgrading from v0.5.1 or newer?</strong> v0.6 is a hard network fork &mdash; older versions can no longer reach the network. Bring your account over in three steps: <p style="color: var(--text-muted); font-size: 0.85rem;">Network &amp; reachability improvements, plus a relay-privacy fix.</p>
<ol style="margin: 0.5rem 0 0.25rem 1.25rem; padding: 0;">
<li>On your current v0.5 install, go to <strong>Settings &rarr; Export</strong>, tick <em>&ldquo;Posts + media + identity key&rdquo;</em>, and save the .zip somewhere safe.</li> <div class="downloads">
<li>Download v0.6 below (APK, AppImage, or Windows installer).</li> <a href="itsgoin-0.7.2.apk" class="download-btn btn-android">
<li>On first launch, choose <strong>&ldquo;Import an Identity&rdquo;</strong> and point it at the .zip.</li> Android APK
</ol> <span class="sub">v0.7.2</span>
<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> </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>
<div class="note" style="margin-top: 0.75rem; border-left: 3px solid var(--text-muted); padding-left: 0.9rem;"> <ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 1rem;">
<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: <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 &mdash; no more lease-expiry surprises.</li>
<ol style="margin: 0.5rem 0 0.25rem 1.25rem; padding: 0;"> <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>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><strong>Mobile HTTP serving when reachable.</strong> Phones whose router cooperates with TCP port mapping can now serve <code>/p/&lt;post&gt;</code> directly for browser fetches. No mode switch &mdash; this just works when the network allows it.</li>
<li>In v0.5.3, go to <strong>Settings &rarr; Export</strong> and save the bundle.</li> <li><strong>Anchor-mode self-heals across network changes.</strong> A new watcher observes the live port mapping. Mapping lost &gt;5min &rarr; anchor mode clears (don't keep advertising an unreachable address). Mapping restored &rarr; anchor mode comes back on, no restart needed. Roaming between UPnP-capable WiFi networks no longer requires a reboot.</li>
<li>Install v0.6.0 below and import that bundle on first launch.</li> <li><strong>Shorter share URLs.</strong> Share links now contain only the post ID &mdash; <code>itsgoin.net/p/&lt;post&gt;</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>
</ol> <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 &mdash; server bandwidth shouldn't be silently consumed either. Hole-punch coordination (small signaling) is unaffected.</li>
<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 &mdash; it no longer connects to the live network.</p> <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>
</div> </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 &amp; UX improvements are local.</p>
<h2 style="margin-top: 2rem;">v0.7.1 &mdash; May 15, 2026</h2> <h2 style="margin-top: 2rem;">v0.7.1 &mdash; 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> <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>