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

View file

@ -3692,8 +3692,14 @@ impl Node {
}
}
// Step 7: Session relay fallback — if intro was accepted but hole punch failed
if let (Some(intro_id), Some(relay_peer)) = (last_intro_id, last_relay_peer) {
// Step 7: Session relay fallback — only if BOTH the introducer
// signaled relay availability AND this node has opted in to
// using session relay (`relay.session_relay_enabled`). Default
// is opt-out: hole-punch failure does NOT silently fall back
// to byte-relaying through a third party.
if !self.network.conn_handle().is_session_relay_enabled().await {
debug!(target = hex::encode(peer_id), "Session relay opt-out — skipping relay fallback");
} else if let (Some(intro_id), Some(relay_peer)) = (last_intro_id, last_relay_peer) {
if last_relay_available {
info!(
target = hex::encode(peer_id),
@ -4577,44 +4583,16 @@ impl Node {
})
}
/// Start UPnP lease renewal cycle. Renews every lease_secs/2.
/// On 3 consecutive failures: clears is_anchor and logs a warning.
/// No-op since v0.7.2: the `portmapper::Client` held inside the active
/// `PortMapping` auto-renews internally in its own background service.
/// Retained for API compatibility with callers in CLI and Tauri until
/// they're cleaned up. Returns `None`.
///
/// TODO(v0.7.x): wire a watcher on `mapping.watch_external()` to clear
/// anchor mode if the external address stays `None` for more than ~5min
/// (parity with the old "3 renewal failures" behavior).
pub fn start_upnp_renewal_cycle(&self) -> Option<tokio::task::JoinHandle<()>> {
let mapping = self.network.upnp_mapping()?;
let local_port = mapping.local_port;
let external_port = mapping.external_addr.port();
let interval_secs = (mapping.lease_secs / 2) as u64;
let network = Arc::clone(&self.network);
let alog = Arc::clone(&self.activity_log);
Some(tokio::spawn(async move {
let mut interval =
tokio::time::interval(std::time::Duration::from_secs(interval_secs));
let mut consecutive_failures: u32 = 0;
loop {
interval.tick().await;
if crate::upnp::renew_upnp_mapping(local_port, external_port).await {
consecutive_failures = 0;
debug!("UPnP: lease renewed (port {})", external_port);
} else {
consecutive_failures += 1;
warn!("UPnP: renewal failed ({}/3)", consecutive_failures);
if consecutive_failures >= 3 {
network.clear_anchor();
if let Ok(mut log) = alog.try_lock() {
log.log(
ActivityLevel::Warn,
ActivityCategory::Connection,
"UPnP lease lost after 3 renewal failures, auto-anchor disabled".into(),
None,
);
}
warn!("UPnP: 3 consecutive renewal failures, auto-anchor disabled");
return; // stop the cycle
}
}
}
}))
None
}
// --- HTTP Post Delivery ---
@ -4669,34 +4647,21 @@ impl Node {
})
}
/// Start UPnP TCP lease renewal cycle alongside the UDP renewal.
/// No-op since v0.7.2 — the TCP `portmapper::Client` auto-renews internally.
pub fn start_upnp_tcp_renewal_cycle(&self) -> Option<tokio::task::JoinHandle<()>> {
if !self.network.has_upnp_tcp() {
return None;
}
let mapping = self.network.upnp_mapping()?;
let local_port = mapping.local_port;
let external_port = mapping.external_addr.port();
let interval_secs = (mapping.lease_secs / 2) as u64;
Some(tokio::spawn(async move {
let mut interval =
tokio::time::interval(std::time::Duration::from_secs(interval_secs));
loop {
interval.tick().await;
if !crate::upnp::renew_upnp_tcp_mapping(local_port, external_port).await {
warn!("UPnP: TCP lease renewal failed");
// Don't stop the cycle — TCP is best-effort
}
}
}))
None
}
/// Generate a share link URL for a public post.
/// Returns None if post is not public or not found.
///
/// URL Phase 1 (v0.7.2): the link contains only the post ID — no author
/// hex, no node addresses. The receiving anchor (itsgoin.net) does the
/// holder lookup itself and serves via redirect or QUIC-proxy fallback.
/// Older URLs with `/{post_hex}/{author_hex}` continue to work — the
/// web handler parses the author hex as optional.
pub async fn generate_share_link(&self, post_id: &PostId) -> anyhow::Result<Option<String>> {
// Look up the post to verify it's public and get the author
let (post, visibility) = {
let (_post, visibility) = {
let store = self.storage.get().await;
match store.get_post_with_visibility(post_id)? {
Some(pv) => pv,
@ -4709,8 +4674,7 @@ impl Node {
}
let post_hex = hex::encode(post_id);
let author_hex = hex::encode(post.author);
Ok(Some(format!("https://itsgoin.net/p/{}/{}", post_hex, author_hex)))
Ok(Some(format!("https://itsgoin.net/p/{}", post_hex)))
}
// --- Engagement API ---