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]]
|
[[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",
|
||||||
|
|
|
||||||
|
|
@ -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]]
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
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>,
|
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());
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ---
|
||||||
|
|
|
||||||
|
|
@ -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 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;
|
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) {}
|
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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">⏻</button>
|
||||||
</div>
|
</div>
|
||||||
<nav id="tabs">
|
<nav id="tabs">
|
||||||
<button class="tab" data-tab="feed"><span class="tab-icon">📰</span><span class="tab-label">Feed</span></button>
|
<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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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 — May 15, 2026</h2>
|
||||||
<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:
|
<p style="color: var(--text-muted); font-size: 0.85rem;">Network & 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 → Export</strong>, tick <em>“Posts + media + identity key”</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>“Import an Identity”</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 — 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/<post></code> directly for browser fetches. No mode switch — this just works when the network allows it.</li>
|
||||||
<li>In v0.5.3, go to <strong>Settings → 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 >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>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 — <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>
|
||||||
</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 — 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 — 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 & UX improvements are local.</p>
|
||||||
|
|
||||||
<h2 style="margin-top: 2rem;">v0.7.1 — May 15, 2026</h2>
|
<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>
|
<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