Compare commits
No commits in common. "master" and "docs/fof-spec-layer1-bio-grants" have entirely different histories.
master
...
docs/fof-s
21 changed files with 504 additions and 1383 deletions
10
Cargo.lock
generated
10
Cargo.lock
generated
|
|
@ -2732,7 +2732,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
|||
|
||||
[[package]]
|
||||
name = "itsgoin-cli"
|
||||
version = "0.7.3"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hex",
|
||||
|
|
@ -2744,7 +2744,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "itsgoin-core"
|
||||
version = "0.7.3"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
|
|
@ -2753,10 +2753,8 @@ dependencies = [
|
|||
"curve25519-dalek",
|
||||
"ed25519-dalek",
|
||||
"hex",
|
||||
"igd-next",
|
||||
"iroh",
|
||||
"jni",
|
||||
"ndk-context",
|
||||
"portmapper",
|
||||
"rand 0.9.2",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
|
|
@ -2769,7 +2767,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "itsgoin-desktop"
|
||||
version = "0.7.3"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "itsgoin-cli"
|
||||
version = "0.7.3"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "itsgoin-core"
|
||||
version = "0.7.3"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
|
@ -19,11 +19,7 @@ ed25519-dalek = { version = "=3.0.0-pre.1", features = ["rand_core", "zeroize"]
|
|||
chacha20poly1305 = "0.10"
|
||||
base64 = "0.22"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
portmapper = "0.14"
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
jni = "0.21"
|
||||
ndk-context = "0.1"
|
||||
igd-next = { version = "0.16", features = ["tokio"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -1,256 +0,0 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the Android `NodeService` foreground service. Called from the
|
||||
/// in-app close button so the network process actually exits rather
|
||||
/// than continuing to run as a foreground service after the Activity
|
||||
/// closes (foreground services are kept alive across Activity exit by
|
||||
/// design).
|
||||
///
|
||||
/// Errors are logged but not propagated — best-effort cleanup before
|
||||
/// `AppHandle::exit(0)` finishes the Activity.
|
||||
pub fn stop_node_service() {
|
||||
if let Err(e) = stop_node_service_inner() {
|
||||
warn!("stop_node_service failed (will exit anyway): {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_node_service_inner() -> Result<(), String> {
|
||||
let ctx = ndk_context::android_context();
|
||||
if ctx.vm().is_null() {
|
||||
return Err("ndk_context: null JavaVM".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 _) };
|
||||
|
||||
// NodeService.stopFromNative(activity)
|
||||
env.call_static_method(
|
||||
"com/itsgoin/app/NodeService",
|
||||
"stopFromNative",
|
||||
"(Landroid/content/Context;)V",
|
||||
&[JValue::Object(&activity)],
|
||||
)
|
||||
.map_err(|e| format!("stopFromNative: {:?}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -145,22 +145,14 @@ pub(crate) async fn hole_punch_parallel(
|
|||
None
|
||||
}
|
||||
|
||||
// EDM port scanner — DISABLED in v0.7.3 (see hole_punch_with_scanning).
|
||||
// Constants and helpers preserved as the refactor target for a raw-UDP
|
||||
// scanner that bypasses iroh's path-store accumulation.
|
||||
|
||||
/// Timeout for each individual scan connect attempt (200ms → ~20 in-flight at 100/sec)
|
||||
#[allow(dead_code)]
|
||||
const SCAN_CONNECT_TIMEOUT_MS: u64 = 200;
|
||||
/// Scan rate: one attempt every 10ms = 100 ports/sec
|
||||
#[allow(dead_code)]
|
||||
const SCAN_INTERVAL_MS: u64 = 10;
|
||||
/// How often to punch peer's anchor-observed address during scanning (seconds).
|
||||
/// Each punch checks if the peer has opened a firewall port matching our actual port.
|
||||
#[allow(dead_code)]
|
||||
const SCAN_PUNCH_INTERVAL_SECS: u64 = 2;
|
||||
/// Maximum scan duration (seconds) — accept the cost for otherwise-impossible connections
|
||||
#[allow(dead_code)]
|
||||
const SCAN_MAX_DURATION_SECS: u64 = 300; // 5 minutes
|
||||
|
||||
/// Global cap on concurrent port-scan hole punches. Each scanner fires
|
||||
|
|
@ -172,63 +164,11 @@ const SCAN_MAX_DURATION_SECS: u64 = 300; // 5 minutes
|
|||
/// at proxy timeouts. A permit is acquired before the scanning loop
|
||||
/// starts and held until the scanner returns; extra callers fall back
|
||||
/// to the cheaper `hole_punch_parallel`.
|
||||
#[allow(dead_code)]
|
||||
fn scanner_semaphore() -> &'static tokio::sync::Semaphore {
|
||||
static SEM: std::sync::OnceLock<tokio::sync::Semaphore> = std::sync::OnceLock::new();
|
||||
SEM.get_or_init(|| tokio::sync::Semaphore::new(1))
|
||||
}
|
||||
|
||||
/// Hole punch orchestrator.
|
||||
///
|
||||
/// **v0.7.3:** the EDM port scanner is DISABLED. We do Step 1 (quick punch to
|
||||
/// the anchor-observed address) → Step 2 (parallel punch over the 30s window
|
||||
/// to all known addresses). No port scan.
|
||||
///
|
||||
/// **Why disabled:** iroh's `Endpoint` accumulates every `endpoint.connect()`
|
||||
/// target into a per-endpoint paths set and probes them all in the background
|
||||
/// under QUIC NAT-traversal. A 100-probes/sec / 5-min scan inserts ~30,000
|
||||
/// paths; iroh then probes all of them. Observed at 22MB/s outbound from a
|
||||
/// single client. Disabled until we replace per-probe `endpoint.connect()`
|
||||
/// with a raw `socket.send_to()` on the endpoint's bound UDP socket — see
|
||||
/// `edm_port_scan_disabled_v0_7_3` for the preserved scanner logic to
|
||||
/// refactor against.
|
||||
///
|
||||
/// Original docstring is preserved on `edm_port_scan_disabled_v0_7_3`.
|
||||
pub(crate) async fn hole_punch_with_scanning(
|
||||
endpoint: &iroh::Endpoint,
|
||||
target: &NodeId,
|
||||
addresses: &[String],
|
||||
_our_profile: crate::types::NatProfile,
|
||||
_peer_profile: crate::types::NatProfile,
|
||||
) -> Option<iroh::endpoint::Connection> {
|
||||
if let Some(conn) = hole_punch_single(endpoint, target, addresses).await {
|
||||
return Some(conn);
|
||||
}
|
||||
hole_punch_parallel(endpoint, target, addresses).await
|
||||
}
|
||||
|
||||
/// **DISABLED in v0.7.3** — kept as the refactor target for a safe replacement.
|
||||
///
|
||||
/// **Why disabled:** iroh's `Endpoint` accumulates every `endpoint.connect()`
|
||||
/// target into a per-endpoint paths set and probes them all in the background
|
||||
/// under QUIC NAT-traversal. A 100-probes/sec / 5-min scan inserts ~30,000
|
||||
/// paths; iroh then probes all of them. Observed at 22MB/s outbound from a
|
||||
/// single client (DoS-grade).
|
||||
///
|
||||
/// **Refactor target:** replace `endpoint.connect()` in the per-probe path
|
||||
/// with a raw `socket.send_to(...)` on the endpoint's bound UDP socket. The
|
||||
/// probe still opens a NAT mapping on our side; we just don't ask iroh to
|
||||
/// manage the path. The every-2s punch retains `endpoint.connect()` so the
|
||||
/// real handshake completes when the peer's punch arrives.
|
||||
///
|
||||
/// Logic worth preserving below: role-based scanner/puncher split,
|
||||
/// `PortWalkIter`, `scanner_semaphore`, `found_tx`/`found_rx` channel
|
||||
/// pattern, deadline + `tokio::select!` orchestration.
|
||||
///
|
||||
/// ---
|
||||
///
|
||||
/// Original docstring:
|
||||
///
|
||||
/// Advanced hole punch with port scanning fallback for EDM/port-restricted NAT.
|
||||
///
|
||||
/// **Role-based behavior** (each side calls this independently):
|
||||
|
|
@ -243,8 +183,7 @@ pub(crate) async fn hole_punch_with_scanning(
|
|||
/// NAT mapping alive and checks if the peer's scan has opened their firewall for us.
|
||||
///
|
||||
/// For both-EDM pairs: both sides scan + punch simultaneously.
|
||||
#[allow(dead_code)]
|
||||
async fn edm_port_scan_disabled_v0_7_3(
|
||||
pub(crate) async fn hole_punch_with_scanning(
|
||||
endpoint: &iroh::Endpoint,
|
||||
target: &NodeId,
|
||||
addresses: &[String],
|
||||
|
|
@ -450,17 +389,12 @@ async fn edm_port_scan_disabled_v0_7_3(
|
|||
|
||||
/// Iterator that walks outward from a base port: base, base+1, base-1, base+2, base-2, ...
|
||||
/// Skips ports outside [1, 65535].
|
||||
///
|
||||
/// Used by `edm_port_scan_disabled_v0_7_3` — preserved for the future
|
||||
/// raw-UDP scanner refactor.
|
||||
#[allow(dead_code)]
|
||||
struct PortWalkIter {
|
||||
base: u16,
|
||||
offset: u32,
|
||||
tried_plus: bool, // within current offset, have we tried base+offset?
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl PortWalkIter {
|
||||
fn new(base: u16) -> Self {
|
||||
Self { base, offset: 0, tried_plus: false }
|
||||
|
|
@ -692,11 +626,6 @@ pub struct ConnectionManager {
|
|||
active_relay_pipes: Arc<AtomicU64>,
|
||||
/// Max concurrent relay pipes
|
||||
max_relay_pipes: usize,
|
||||
/// User opt-in for session relay. Gates both serving as a relay
|
||||
/// (`can_accept_relay_pipe`) and using a peer as a relay on hole-punch
|
||||
/// failure (auto-fallback in node.rs). Default false — relay is opt-in.
|
||||
/// Loaded from the `relay.session_relay_enabled` setting at startup.
|
||||
session_relay_enabled: Arc<AtomicBool>,
|
||||
/// Device profile (for resource limits)
|
||||
#[allow(dead_code)]
|
||||
device_profile: DeviceProfile,
|
||||
|
|
@ -778,13 +707,11 @@ impl ConnectionManager {
|
|||
bind_addr: Option<SocketAddr>,
|
||||
nat_type: crate::types::NatType,
|
||||
nat_mapping: crate::types::NatMapping,
|
||||
session_relay_enabled: bool,
|
||||
) -> Self {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64;
|
||||
let session_relay_enabled = Arc::new(AtomicBool::new(session_relay_enabled));
|
||||
Self {
|
||||
connections: HashMap::new(),
|
||||
endpoint,
|
||||
|
|
@ -805,7 +732,6 @@ impl ConnectionManager {
|
|||
seen_intros: HashMap::new(),
|
||||
active_relay_pipes: Arc::new(AtomicU64::new(0)),
|
||||
max_relay_pipes: profile.max_relay_pipes(),
|
||||
session_relay_enabled,
|
||||
device_profile: profile,
|
||||
unreachable_peers: HashMap::new(),
|
||||
referral_list: HashMap::new(),
|
||||
|
|
@ -3705,28 +3631,11 @@ impl ConnectionManager {
|
|||
&self.active_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.
|
||||
/// Check if we can accept more relay pipes.
|
||||
pub fn can_accept_relay_pipe(&self) -> bool {
|
||||
if !self.session_relay_enabled.load(Ordering::Relaxed) {
|
||||
return false;
|
||||
}
|
||||
self.active_relay_pipes.load(Ordering::Relaxed) < self.max_relay_pipes as u64
|
||||
}
|
||||
|
||||
/// Whether the user has opted in to session relay (both serving and using).
|
||||
pub fn is_session_relay_enabled(&self) -> bool {
|
||||
self.session_relay_enabled.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Update the session-relay opt-in flag. The caller is responsible for
|
||||
/// persisting the setting to storage; this only updates the in-memory
|
||||
/// flag that gates the relay accept and auto-use paths.
|
||||
pub fn set_session_relay_enabled(&self, enabled: bool) {
|
||||
self.session_relay_enabled.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Get our node ID.
|
||||
pub fn our_node_id(&self) -> &NodeId {
|
||||
&self.our_node_id
|
||||
|
|
@ -6672,13 +6581,6 @@ pub enum ConnCommand {
|
|||
CanAcceptRelayPipe {
|
||||
reply: oneshot::Sender<bool>,
|
||||
},
|
||||
IsSessionRelayEnabled {
|
||||
reply: oneshot::Sender<bool>,
|
||||
},
|
||||
SetSessionRelayEnabled {
|
||||
enabled: bool,
|
||||
reply: oneshot::Sender<()>,
|
||||
},
|
||||
BuildAnchorAdvertisedAddr {
|
||||
reply: oneshot::Sender<Option<String>>,
|
||||
},
|
||||
|
|
@ -7076,18 +6978,6 @@ impl ConnHandle {
|
|||
rx.await.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub async fn is_session_relay_enabled(&self) -> bool {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let _ = self.tx.send(ConnCommand::IsSessionRelayEnabled { reply: tx }).await;
|
||||
rx.await.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub async fn set_session_relay_enabled(&self, enabled: bool) {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let _ = self.tx.send(ConnCommand::SetSessionRelayEnabled { enabled, reply: tx }).await;
|
||||
let _ = rx.await;
|
||||
}
|
||||
|
||||
pub async fn build_anchor_advertised_addr(&self) -> Option<String> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let _ = self.tx.send(ConnCommand::BuildAnchorAdvertisedAddr { reply: tx }).await;
|
||||
|
|
@ -8012,15 +7902,6 @@ impl ConnectionActor {
|
|||
let cm = self.cm.lock().await;
|
||||
let _ = reply.send(cm.can_accept_relay_pipe());
|
||||
}
|
||||
ConnCommand::IsSessionRelayEnabled { reply } => {
|
||||
let cm = self.cm.lock().await;
|
||||
let _ = reply.send(cm.is_session_relay_enabled());
|
||||
}
|
||||
ConnCommand::SetSessionRelayEnabled { enabled, reply } => {
|
||||
let cm = self.cm.lock().await;
|
||||
cm.set_session_relay_enabled(enabled);
|
||||
let _ = reply.send(());
|
||||
}
|
||||
ConnCommand::BuildAnchorAdvertisedAddr { reply } => {
|
||||
let cm = self.cm.lock().await;
|
||||
let _ = reply.send(cm.build_anchor_advertised_addr());
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
pub mod activity;
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod android_wifi;
|
||||
pub mod blob;
|
||||
pub mod connection;
|
||||
pub mod content;
|
||||
|
|
|
|||
|
|
@ -35,12 +35,10 @@ pub struct Network {
|
|||
/// Growth loop signal sender (set by start_growth_loop)
|
||||
growth_tx: tokio::sync::Mutex<Option<tokio::sync::mpsc::Sender<()>>>,
|
||||
activity_log: Arc<std::sync::Mutex<ActivityLog>>,
|
||||
/// UDP port mapping (UPnP/NAT-PMP/PCP) for the QUIC socket.
|
||||
/// Holding this keeps the auto-renewing portmapper service alive.
|
||||
upnp_mapping: Option<crate::upnp::PortMapping>,
|
||||
/// TCP port mapping for HTTP serving, if available. Holding it keeps
|
||||
/// the lease renewed by the portmapper background service.
|
||||
tcp_mapping: Option<crate::upnp::PortMapping>,
|
||||
/// UPnP mapping result (None if no mapping or on mobile)
|
||||
upnp_mapping: Option<crate::upnp::UpnpMapping>,
|
||||
/// Whether UPnP TCP mapping succeeded (for HTTP serving)
|
||||
has_upnp_tcp: bool,
|
||||
/// Whether this node has a public IPv6 address
|
||||
has_public_v6: bool,
|
||||
/// Stable bind address (from --bind flag), passed to ConnectionManager for anchor advertised address
|
||||
|
|
@ -122,18 +120,12 @@ impl Network {
|
|||
|
||||
let our_node_id = *endpoint.id().as_bytes();
|
||||
|
||||
// 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.
|
||||
// Best-effort UPnP port mapping (desktop only, skip if --bind was used)
|
||||
let is_mobile = cfg!(target_os = "android") || cfg!(target_os = "ios");
|
||||
let upnp_mapping = if bind_addr.is_none() {
|
||||
let upnp_mapping = if !is_mobile && bind_addr.is_none() {
|
||||
let bound_port = endpoint.bound_sockets().first()
|
||||
.map(|s| s.port()).unwrap_or(0);
|
||||
crate::upnp::PortMapping::try_udp(bound_port).await
|
||||
crate::upnp::try_upnp_mapping(bound_port).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
|
@ -223,14 +215,6 @@ impl Network {
|
|||
}
|
||||
|
||||
let upnp_external_addr = upnp_mapping.as_ref().map(|m| m.external_addr);
|
||||
let session_relay_enabled = {
|
||||
let s = storage.get().await;
|
||||
s.get_setting("relay.session_relay_enabled")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| v == "true")
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let conn_mgr = ConnectionManager::new(
|
||||
endpoint.clone(),
|
||||
Arc::clone(&storage),
|
||||
|
|
@ -244,26 +228,18 @@ impl Network {
|
|||
bind_addr,
|
||||
nat_type,
|
||||
nat_mapping,
|
||||
session_relay_enabled,
|
||||
);
|
||||
let conn_mgr = Arc::new(Mutex::new(conn_mgr));
|
||||
|
||||
// Spawn actor wrapping the same Arc<Mutex<CM>> (Phase 1: additive)
|
||||
let conn_handle = ConnectionActor::spawn_with_arc(Arc::clone(&conn_mgr)).await;
|
||||
|
||||
// TCP port mapping for HTTP post delivery. Run on every platform —
|
||||
// mobile devices with permissive NAT (UPnP/PCP-TCP working) can serve
|
||||
// HTTP for direct browser fetches. We attempt independently of the
|
||||
// UDP mapping result because the protocols are separate calls in
|
||||
// portmapper.
|
||||
let tcp_mapping = if bind_addr.is_none() {
|
||||
let bound_port = endpoint.bound_sockets().first()
|
||||
.map(|s| s.port()).unwrap_or(0);
|
||||
crate::upnp::PortMapping::try_tcp(bound_port).await
|
||||
// TCP UPnP mapping for HTTP post delivery (only if UDP UPnP succeeded)
|
||||
let has_upnp_tcp = if let Some(ref mapping) = upnp_mapping {
|
||||
crate::upnp::try_upnp_tcp_mapping(mapping.local_port, mapping.external_addr.port()).await
|
||||
} else {
|
||||
None
|
||||
false
|
||||
};
|
||||
let has_upnp_tcp = tcp_mapping.is_some();
|
||||
|
||||
info!(
|
||||
node_id = %endpoint.id(),
|
||||
|
|
@ -283,79 +259,6 @@ impl Network {
|
|||
info!(role = %device_role, "CDN replication role determined");
|
||||
conn_handle.set_device_role(device_role);
|
||||
|
||||
// Anchor reachability watcher: tracks the UDP port mapping over time
|
||||
// and adjusts anchor mode without restart.
|
||||
// - Mapping lost (None) for >5 min → clear anchor mode (don't keep
|
||||
// advertising an unreachable address). Replaces the old
|
||||
// "3 consecutive UPnP renewal failures" logic.
|
||||
// - Mapping restored (None → Some) → re-evaluate auto-anchor. If
|
||||
// this node qualifies (desktop with the new mapping in hand), set
|
||||
// anchor back on. Mobile never auto-anchors regardless.
|
||||
// The watcher exits when the watch channel closes (i.e., when the
|
||||
// PortMapping is dropped at network shutdown).
|
||||
if let Some(ref mapping) = upnp_mapping {
|
||||
let mut rx = mapping.watch_external();
|
||||
let is_anchor_w = Arc::clone(&is_anchor);
|
||||
let alog_w = Arc::clone(&activity_log);
|
||||
tokio::spawn(async move {
|
||||
let mut down_since: Option<std::time::Instant> = None;
|
||||
let threshold = std::time::Duration::from_secs(300);
|
||||
loop {
|
||||
if rx.changed().await.is_err() {
|
||||
// Channel closed — mapping was dropped, watcher done.
|
||||
return;
|
||||
}
|
||||
let current = *rx.borrow_and_update();
|
||||
match current {
|
||||
Some(addr) => {
|
||||
// Mapping restored. If we'd lost anchor due to a
|
||||
// previous drop, restore it now. Skip on mobile
|
||||
// (cellular IPs look public but aren't anchorable).
|
||||
let recovered = down_since.is_some();
|
||||
down_since = None;
|
||||
if recovered && !is_mobile && !is_anchor_w.load(Ordering::Relaxed) {
|
||||
is_anchor_w.store(true, Ordering::Relaxed);
|
||||
if let Ok(mut log) = alog_w.try_lock() {
|
||||
log.log(
|
||||
ActivityLevel::Info,
|
||||
ActivityCategory::Connection,
|
||||
format!(
|
||||
"Port mapping restored ({}), anchor mode re-enabled",
|
||||
addr
|
||||
),
|
||||
None,
|
||||
);
|
||||
}
|
||||
info!(external = %addr, "Port mapping restored — anchor mode re-enabled");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let now = std::time::Instant::now();
|
||||
let t = *down_since.get_or_insert(now);
|
||||
if now.duration_since(t) > threshold
|
||||
&& is_anchor_w.load(Ordering::Relaxed)
|
||||
{
|
||||
is_anchor_w.store(false, Ordering::Relaxed);
|
||||
if let Ok(mut log) = alog_w.try_lock() {
|
||||
log.log(
|
||||
ActivityLevel::Warn,
|
||||
ActivityCategory::Connection,
|
||||
"Port mapping lost >5min, anchor mode cleared"
|
||||
.into(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
warn!(
|
||||
"Port mapping lost for >5min — anchor mode cleared"
|
||||
);
|
||||
// Don't return — keep watching for recovery so we can re-anchor.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
endpoint,
|
||||
storage,
|
||||
|
|
@ -366,7 +269,7 @@ impl Network {
|
|||
growth_tx: tokio::sync::Mutex::new(None),
|
||||
activity_log,
|
||||
upnp_mapping,
|
||||
tcp_mapping,
|
||||
has_upnp_tcp,
|
||||
has_public_v6,
|
||||
bind_addr,
|
||||
device_role,
|
||||
|
|
@ -430,8 +333,8 @@ impl Network {
|
|||
addrs
|
||||
}
|
||||
|
||||
/// Get the active UDP port mapping (UPnP/NAT-PMP/PCP), if any.
|
||||
pub fn upnp_mapping(&self) -> Option<&crate::upnp::PortMapping> {
|
||||
/// Get the UPnP mapping, if one was successfully acquired.
|
||||
pub fn upnp_mapping(&self) -> Option<&crate::upnp::UpnpMapping> {
|
||||
self.upnp_mapping.as_ref()
|
||||
}
|
||||
|
||||
|
|
@ -456,7 +359,7 @@ impl Network {
|
|||
|
||||
/// Whether this node can serve HTTP (has TCP reachability).
|
||||
pub fn is_http_capable(&self) -> bool {
|
||||
self.tcp_mapping.is_some() || self.has_public_v6 || self.bind_addr.is_some()
|
||||
self.has_upnp_tcp || self.has_public_v6 || self.bind_addr.is_some()
|
||||
}
|
||||
|
||||
/// Get the port to bind the HTTP TCP listener on (same as QUIC).
|
||||
|
|
@ -470,9 +373,9 @@ impl Network {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether the TCP port mapping for HTTP serving is active.
|
||||
/// Whether UPnP TCP mapping is active.
|
||||
pub fn has_upnp_tcp(&self) -> bool {
|
||||
self.tcp_mapping.is_some()
|
||||
self.has_upnp_tcp
|
||||
}
|
||||
|
||||
/// Whether this node has a public IPv6 address.
|
||||
|
|
@ -480,19 +383,15 @@ impl Network {
|
|||
self.has_public_v6
|
||||
}
|
||||
|
||||
/// Whether this node has a port mapping (UDP or TCP) from any of
|
||||
/// UPnP-IGD / NAT-PMP / PCP.
|
||||
/// Whether this node has UPnP mapping (UDP or TCP).
|
||||
pub fn has_upnp(&self) -> bool {
|
||||
self.upnp_mapping.is_some() || self.tcp_mapping.is_some()
|
||||
self.upnp_mapping.is_some() || self.has_upnp_tcp
|
||||
}
|
||||
|
||||
|
||||
/// Get external HTTP address string for InitialExchange advertisement.
|
||||
pub fn http_addr(&self) -> Option<String> {
|
||||
// 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 {
|
||||
if let Some(ref mapping) = self.upnp_mapping {
|
||||
return Some(mapping.external_addr.to_string());
|
||||
}
|
||||
if let Some(bind) = self.bind_addr {
|
||||
|
|
@ -2253,20 +2152,18 @@ impl Network {
|
|||
|
||||
pub async fn shutdown(self) -> anyhow::Result<()> {
|
||||
// Remove UPnP port mapping before closing endpoint
|
||||
// Dropping the PortMapping triggers the portmapper Client's
|
||||
// AbortOnDropHandle and stops the renewal task. We don't explicitly
|
||||
// release here because `self` doesn't move; the field will drop
|
||||
// naturally when the Network goes out of scope.
|
||||
if let Some(ref mapping) = self.upnp_mapping {
|
||||
crate::upnp::remove_upnp_mapping(mapping.external_addr.port()).await;
|
||||
}
|
||||
self.endpoint.close().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shutdown via Arc reference — closes the endpoint, causing all background tasks to exit.
|
||||
pub async fn shutdown_ref(&self) {
|
||||
// Dropping the PortMapping triggers the portmapper Client's
|
||||
// AbortOnDropHandle and stops the renewal task. We don't explicitly
|
||||
// release here because `self` doesn't move; the field will drop
|
||||
// naturally when the Network goes out of scope.
|
||||
if let Some(ref mapping) = self.upnp_mapping {
|
||||
crate::upnp::remove_upnp_mapping(mapping.external_addr.port()).await;
|
||||
}
|
||||
self.endpoint.close().await;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -92,175 +92,6 @@ async fn ensure_initial_v_me(
|
|||
generate_and_store_initial_v_me(&s, persona_id, now_ms)
|
||||
}
|
||||
|
||||
/// Probe a list of anchors with batched parallelism, returning the first
|
||||
/// successful NodeId. Remaining probes continue in background tasks after
|
||||
/// first success and naturally register additional mesh connections.
|
||||
///
|
||||
/// **Parameters fixed in v0.7.3:**
|
||||
/// - 3 anchors in flight at a time
|
||||
/// - 2-second stagger between batch dispatches
|
||||
/// - 10s per-anchor connect timeout
|
||||
/// - Failed probes to anchors with `last_seen_ms` older than 3 days
|
||||
/// auto-delete from `known_anchors` (self-healing pruning)
|
||||
///
|
||||
/// Returns `None` only when every probe completed without success.
|
||||
async fn probe_anchors_batched(
|
||||
anchors: Vec<(NodeId, Vec<std::net::SocketAddr>)>,
|
||||
network: Arc<crate::network::Network>,
|
||||
storage: Arc<StoragePool>,
|
||||
self_node_id: NodeId,
|
||||
label: &'static str,
|
||||
) -> Option<NodeId> {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
const BATCH_SIZE: usize = 3;
|
||||
const BATCH_STAGGER_SECS: u64 = 2;
|
||||
const PER_ANCHOR_TIMEOUT_SECS: u64 = 10;
|
||||
const STALE_THRESHOLD_MS: u64 = 3 * 86_400 * 1000;
|
||||
|
||||
let total = anchors.len();
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (success_tx, success_rx) = tokio::sync::oneshot::channel::<NodeId>();
|
||||
let success_tx = Arc::new(tokio::sync::Mutex::new(Some(success_tx)));
|
||||
let completed = Arc::new(AtomicUsize::new(0));
|
||||
let all_done = Arc::new(tokio::sync::Notify::new());
|
||||
|
||||
// Dispatcher: spawns per-anchor tasks in batches of BATCH_SIZE,
|
||||
// sleeping BATCH_STAGGER_SECS between batches. The per-anchor tasks
|
||||
// continue running after the dispatcher exits.
|
||||
let dispatcher = {
|
||||
let network = Arc::clone(&network);
|
||||
let storage = Arc::clone(&storage);
|
||||
let success_tx = Arc::clone(&success_tx);
|
||||
let completed = Arc::clone(&completed);
|
||||
let all_done = Arc::clone(&all_done);
|
||||
tokio::spawn(async move {
|
||||
let mut iter = anchors.into_iter();
|
||||
loop {
|
||||
let batch: Vec<_> = (&mut iter).take(BATCH_SIZE).collect();
|
||||
if batch.is_empty() {
|
||||
break;
|
||||
}
|
||||
let more = iter.size_hint().0 > 0;
|
||||
for (nid, addrs) in batch {
|
||||
let network = Arc::clone(&network);
|
||||
let storage = Arc::clone(&storage);
|
||||
let success_tx = Arc::clone(&success_tx);
|
||||
let completed = Arc::clone(&completed);
|
||||
let all_done = Arc::clone(&all_done);
|
||||
tokio::spawn(async move {
|
||||
let result = probe_one_anchor(&network, &storage, nid, addrs, self_node_id, label).await;
|
||||
if let Some(nid) = result {
|
||||
let mut guard = success_tx.lock().await;
|
||||
if let Some(sender) = guard.take() {
|
||||
let _ = sender.send(nid);
|
||||
}
|
||||
}
|
||||
let prev = completed.fetch_add(1, Ordering::SeqCst);
|
||||
if prev + 1 == total {
|
||||
all_done.notify_one();
|
||||
}
|
||||
});
|
||||
}
|
||||
if more {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(BATCH_STAGGER_SECS)).await;
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Race: first success vs all probes complete unsuccessfully.
|
||||
let result = tokio::select! {
|
||||
Ok(nid) = success_rx => Some(nid),
|
||||
_ = all_done.notified() => None,
|
||||
};
|
||||
|
||||
// Detach the dispatcher; in-flight per-anchor tasks continue.
|
||||
drop(dispatcher);
|
||||
|
||||
let _ = BATCH_STAGGER_SECS; // silence unused-const if compiler is picky
|
||||
let _ = PER_ANCHOR_TIMEOUT_SECS;
|
||||
let _ = STALE_THRESHOLD_MS;
|
||||
result
|
||||
}
|
||||
|
||||
async fn probe_one_anchor(
|
||||
network: &crate::network::Network,
|
||||
storage: &Arc<StoragePool>,
|
||||
nid: NodeId,
|
||||
addrs: Vec<std::net::SocketAddr>,
|
||||
self_node_id: NodeId,
|
||||
label: &'static str,
|
||||
) -> Option<NodeId> {
|
||||
const PER_ANCHOR_TIMEOUT_SECS: u64 = 10;
|
||||
const STALE_THRESHOLD_MS: u64 = 3 * 86_400 * 1000;
|
||||
|
||||
if nid == self_node_id || network.is_peer_connected_or_session(&nid).await {
|
||||
return None;
|
||||
}
|
||||
let endpoint_id = match iroh::EndpointId::from_bytes(&nid) {
|
||||
Ok(eid) => eid,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let mut addr = iroh::EndpointAddr::from(endpoint_id);
|
||||
for sa in &addrs {
|
||||
addr = addr.with_ip_addr(*sa);
|
||||
}
|
||||
info!(peer = hex::encode(&nid), label, "Trying anchor");
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(PER_ANCHOR_TIMEOUT_SECS),
|
||||
network.connect_to_anchor(nid, addr),
|
||||
).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(())) => {
|
||||
info!(peer = hex::encode(&nid), label, "Connected to anchor");
|
||||
Some(nid)
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
debug!(error = %e, peer = hex::encode(&nid), label, "Anchor connect failed");
|
||||
maybe_prune_stale_anchor(storage, &nid, STALE_THRESHOLD_MS).await;
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
debug!(peer = hex::encode(&nid), label, "Anchor connect timed out");
|
||||
maybe_prune_stale_anchor(storage, &nid, STALE_THRESHOLD_MS).await;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// If the anchor's last successful contact was more than `threshold_ms`
|
||||
/// ago, delete it from `known_anchors`. Future startups won't waste a
|
||||
/// probe slot on it. Anchors that were recently successful are preserved
|
||||
/// even when they fail a single probe (likely transient).
|
||||
async fn maybe_prune_stale_anchor(
|
||||
storage: &Arc<StoragePool>,
|
||||
nid: &NodeId,
|
||||
threshold_ms: u64,
|
||||
) {
|
||||
let s = storage.get().await;
|
||||
let last_seen_ms = match s.get_known_anchor_last_seen(nid) {
|
||||
Ok(Some(ms)) => ms,
|
||||
_ => return,
|
||||
};
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
if now_ms > last_seen_ms && now_ms - last_seen_ms > threshold_ms {
|
||||
let _ = s.delete_known_anchor(nid);
|
||||
debug!(
|
||||
peer = hex::encode(nid),
|
||||
age_ms = now_ms - last_seen_ms,
|
||||
"Pruned stale anchor (>3 days since last success + failed probe)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// Create or open a node in the given data directory (Desktop profile)
|
||||
pub async fn open(data_dir: impl AsRef<Path>) -> anyhow::Result<Self> {
|
||||
|
|
@ -441,11 +272,6 @@ impl Node {
|
|||
|
||||
/// Bootstrap: connect to anchors, pull initial data, NAT probe, referrals.
|
||||
/// Can be called during open_with_bind (blocking startup) or deferred to background.
|
||||
///
|
||||
/// v0.7.3: anchor probing is batched (3 in flight, 2s stagger between batches,
|
||||
/// 10s per-anchor timeout, first success unblocks downstream, remaining probes
|
||||
/// continue in background and naturally fill peer connections). Failed probes
|
||||
/// to anchors >3 days stale auto-prune from `known_anchors`.
|
||||
pub async fn run_bootstrap(&self, data_dir: &Path) -> anyhow::Result<()> {
|
||||
let storage = &self.storage;
|
||||
let network = &self.network;
|
||||
|
|
@ -653,28 +479,57 @@ impl Node {
|
|||
let (discovered, bootstrap_known): (Vec<_>, Vec<_>) = known.into_iter()
|
||||
.partition(|(nid, _)| !bootstrap_anchor_ids.contains(nid));
|
||||
|
||||
// Phase 1: probe discovered (non-bootstrap) anchors in batches.
|
||||
// First success returns immediately; remaining probes continue in
|
||||
// background. Failed probes to anchors >3 days stale auto-prune.
|
||||
let mut connected_anchor = probe_anchors_batched(
|
||||
discovered.clone(),
|
||||
network.clone(),
|
||||
Arc::clone(storage),
|
||||
node_id,
|
||||
"discovered",
|
||||
).await;
|
||||
// Phase 1: Try discovered (non-bootstrap) anchors first
|
||||
let mut connected_anchor = None;
|
||||
for (anchor_nid, anchor_addrs) in &discovered {
|
||||
if *anchor_nid == node_id || network.is_peer_connected_or_session(anchor_nid).await {
|
||||
continue;
|
||||
}
|
||||
let endpoint_id = match iroh::EndpointId::from_bytes(anchor_nid) {
|
||||
Ok(eid) => eid,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let mut addr = iroh::EndpointAddr::from(endpoint_id);
|
||||
for sa in anchor_addrs {
|
||||
addr = addr.with_ip_addr(*sa);
|
||||
}
|
||||
info!(peer = hex::encode(anchor_nid), "Trying discovered anchor");
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(10), network.connect_to_anchor(*anchor_nid, addr)).await {
|
||||
Ok(Ok(())) => {
|
||||
info!(peer = hex::encode(anchor_nid), "Connected to discovered anchor");
|
||||
connected_anchor = Some(*anchor_nid);
|
||||
break;
|
||||
}
|
||||
Ok(Err(e)) => debug!(error = %e, peer = hex::encode(anchor_nid), "Discovered anchor: connect failed"),
|
||||
Err(_) => debug!(peer = hex::encode(anchor_nid), "Discovered anchor: connect timed out"),
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: bootstrap anchors as fallback — only fires if every
|
||||
// Phase 1 entry failed. Preserves the load-distribution intent
|
||||
// (don't smash the central anchor when discovered anchors work).
|
||||
// Phase 2: Fall back to bootstrap anchors only if no discovered anchor worked
|
||||
if connected_anchor.is_none() {
|
||||
connected_anchor = probe_anchors_batched(
|
||||
bootstrap_known.clone(),
|
||||
network.clone(),
|
||||
Arc::clone(storage),
|
||||
node_id,
|
||||
"bootstrap",
|
||||
).await;
|
||||
for (anchor_nid, anchor_addrs) in &bootstrap_known {
|
||||
if *anchor_nid == node_id || network.is_peer_connected_or_session(anchor_nid).await {
|
||||
continue;
|
||||
}
|
||||
let endpoint_id = match iroh::EndpointId::from_bytes(anchor_nid) {
|
||||
Ok(eid) => eid,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let mut addr = iroh::EndpointAddr::from(endpoint_id);
|
||||
for sa in anchor_addrs {
|
||||
addr = addr.with_ip_addr(*sa);
|
||||
}
|
||||
info!(peer = hex::encode(anchor_nid), "Trying bootstrap anchor (fallback)");
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(10), network.connect_to_anchor(*anchor_nid, addr)).await {
|
||||
Ok(Ok(())) => {
|
||||
info!(peer = hex::encode(anchor_nid), "Connected to bootstrap anchor");
|
||||
connected_anchor = Some(*anchor_nid);
|
||||
break;
|
||||
}
|
||||
Ok(Err(e)) => debug!(error = %e, peer = hex::encode(anchor_nid), "Bootstrap anchor: connect failed"),
|
||||
Err(_) => debug!(peer = hex::encode(anchor_nid), "Bootstrap anchor: connect timed out"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: NAT probe + referrals from whichever anchor we connected to
|
||||
|
|
@ -1938,20 +1793,6 @@ impl Node {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Keep posting_identities.display_name in sync with the
|
||||
// profile post so the Personas list and any UI reading
|
||||
// PostingIdentity sees the current name (not the original
|
||||
// empty/auto-gen one). The upsert preserves the persona's
|
||||
// secret_seed / created_at; only display_name changes.
|
||||
if let Ok(Some(existing)) = storage.get_posting_identity(&posting_id) {
|
||||
let updated = crate::types::PostingIdentity {
|
||||
node_id: existing.node_id,
|
||||
secret_seed: existing.secret_seed,
|
||||
display_name: display_name.clone(),
|
||||
created_at: existing.created_at,
|
||||
};
|
||||
let _ = storage.upsert_posting_identity(&updated);
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate via neighbor-manifest header diffs like any other post.
|
||||
|
|
@ -3587,12 +3428,7 @@ impl Node {
|
|||
|
||||
pub async fn get_redundancy_summary(&self) -> anyhow::Result<(usize, usize, usize, usize)> {
|
||||
let storage = self.storage.get().await;
|
||||
// Posts are authored by posting identities (personas), not the
|
||||
// network NodeId. Use every persona on this device so the
|
||||
// summary counts all of my posts across personas.
|
||||
let author_ids: Vec<NodeId> = storage.list_posting_identities()?
|
||||
.into_iter().map(|p| p.node_id).collect();
|
||||
storage.get_redundancy_summary(&author_ids, 3_600_000)
|
||||
storage.get_redundancy_summary(&self.node_id, 3_600_000)
|
||||
}
|
||||
|
||||
// ---- Networking ----
|
||||
|
|
@ -3837,14 +3673,8 @@ impl Node {
|
|||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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) {
|
||||
if last_relay_available {
|
||||
info!(
|
||||
target = hex::encode(peer_id),
|
||||
|
|
@ -4728,16 +4558,44 @@ impl Node {
|
|||
})
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// Start UPnP lease renewal cycle. Renews every lease_secs/2.
|
||||
/// On 3 consecutive failures: clears is_anchor and logs a warning.
|
||||
pub fn start_upnp_renewal_cycle(&self) -> Option<tokio::task::JoinHandle<()>> {
|
||||
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;
|
||||
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 ---
|
||||
|
|
@ -4792,21 +4650,34 @@ impl Node {
|
|||
})
|
||||
}
|
||||
|
||||
/// No-op since v0.7.2 — the TCP `portmapper::Client` auto-renews internally.
|
||||
/// Start UPnP TCP lease renewal cycle alongside the UDP renewal.
|
||||
pub fn start_upnp_tcp_renewal_cycle(&self) -> Option<tokio::task::JoinHandle<()>> {
|
||||
None
|
||||
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
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
let (_post, visibility) = {
|
||||
// Look up the post to verify it's public and get the author
|
||||
let (post, visibility) = {
|
||||
let store = self.storage.get().await;
|
||||
match store.get_post_with_visibility(post_id)? {
|
||||
Some(pv) => pv,
|
||||
|
|
@ -4819,7 +4690,8 @@ impl Node {
|
|||
}
|
||||
|
||||
let post_hex = hex::encode(post_id);
|
||||
Ok(Some(format!("https://itsgoin.net/p/{}", post_hex)))
|
||||
let author_hex = hex::encode(post.author);
|
||||
Ok(Some(format!("https://itsgoin.net/p/{}/{}", post_hex, author_hex)))
|
||||
}
|
||||
|
||||
// --- Engagement API ---
|
||||
|
|
|
|||
|
|
@ -2248,33 +2248,6 @@ impl Storage {
|
|||
Ok(result)
|
||||
}
|
||||
|
||||
/// Get the last successful contact time (ms since epoch) for a known anchor.
|
||||
/// Returns None if the anchor isn't in the table.
|
||||
pub fn get_known_anchor_last_seen(&self, node_id: &NodeId) -> anyhow::Result<Option<u64>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT last_seen_ms FROM known_anchors WHERE node_id = ?1",
|
||||
)?;
|
||||
let mut rows = stmt.query(params![node_id.as_slice()])?;
|
||||
if let Some(row) = rows.next()? {
|
||||
let ms: i64 = row.get(0)?;
|
||||
Ok(Some(ms as u64))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a known anchor entry. Used by the bootstrap connect path
|
||||
/// when a stale anchor (>3 days since last successful contact) fails
|
||||
/// to connect — self-healing pruning so future startups don't re-try
|
||||
/// long-dead entries.
|
||||
pub fn delete_known_anchor(&self, node_id: &NodeId) -> anyhow::Result<()> {
|
||||
self.conn.execute(
|
||||
"DELETE FROM known_anchors WHERE node_id = ?1",
|
||||
params![node_id.as_slice()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prune known anchors to keep at most `max` entries (by highest success_count).
|
||||
pub fn prune_known_anchors(&self, max: usize) -> anyhow::Result<usize> {
|
||||
let count: i64 = self.conn.query_row(
|
||||
|
|
@ -3156,30 +3129,17 @@ impl Storage {
|
|||
|
||||
/// Get a summary of redundancy across all our authored posts.
|
||||
/// Returns (total, zero_replicas, one_replica, two_plus_replicas).
|
||||
/// Redundancy summary across every post authored by ANY of the
|
||||
/// device's posting identities (personas). Pre-v0.6.0 this matched
|
||||
/// on the device's network NodeId, but the network/posting-ID
|
||||
/// split moved authorship to the posting identity — so the old
|
||||
/// query returned 0 of my posts and the UI showed 0 redundancy
|
||||
/// for new posts.
|
||||
pub fn get_redundancy_summary(
|
||||
&self,
|
||||
author_ids: &[NodeId],
|
||||
our_node_id: &NodeId,
|
||||
staleness_ms: u64,
|
||||
) -> anyhow::Result<(usize, usize, usize, usize)> {
|
||||
if author_ids.is_empty() {
|
||||
return Ok((0, 0, 0, 0));
|
||||
}
|
||||
let cutoff = now_ms() - staleness_ms as i64;
|
||||
let placeholders: Vec<&str> = (0..author_ids.len()).map(|_| "?").collect();
|
||||
let sql = format!(
|
||||
"SELECT p.id FROM posts p WHERE p.author IN ({})",
|
||||
placeholders.join(","),
|
||||
);
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
let params_iter = rusqlite::params_from_iter(author_ids.iter().map(|n| n.to_vec()));
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT p.id FROM posts p WHERE p.author = ?1",
|
||||
)?;
|
||||
let post_ids: Vec<PostId> = {
|
||||
let mut rows = stmt.query(params_iter)?;
|
||||
let mut rows = stmt.query(params![our_node_id.as_slice()])?;
|
||||
let mut ids = Vec::new();
|
||||
while let Some(row) = rows.next()? {
|
||||
ids.push(blob_to_postid(row.get(0)?)?);
|
||||
|
|
|
|||
|
|
@ -1,181 +1,243 @@
|
|||
//! NAT port mapping via UPnP-IGD, NAT-PMP, and PCP.
|
||||
//!
|
||||
//! Wraps the `portmapper` crate (also used internally by iroh) which runs
|
||||
//! all three protocols in parallel and auto-renews the lease in a background
|
||||
//! task. This module is named `upnp` for historical reasons — by v0.7.2 it
|
||||
//! covers more than UPnP.
|
||||
//!
|
||||
//! ## Protocols
|
||||
//! - **UPnP-IGD** — long-standing consumer-router default. Discovery uses
|
||||
//! SSDP multicast on 239.255.255.250:1900. Behavior on Hold harbor routers
|
||||
//! varies; many ship with UPnP disabled by default.
|
||||
//! - **NAT-PMP** (RFC 6886) — Apple lineage; widespread on routers that
|
||||
//! ever shipped Bonjour. Unicast to the gateway on UDP/5351.
|
||||
//! - **PCP** (RFC 6887) — modern IETF-track successor to NAT-PMP. Unicast
|
||||
//! on UDP/5351. Supports both IPv4 NAT mapping and IPv6 firewall pinholes
|
||||
//! (the latter via `AddPinhole`-shaped requests). Increasingly common in
|
||||
//! modern routers.
|
||||
//!
|
||||
//! All three are attempted in parallel by portmapper; the first one to
|
||||
//! respond wins. PCP responses arrive sub-second when present; SSDP wakes
|
||||
//! up in 1–3s.
|
||||
//!
|
||||
//! ## Per-platform contract
|
||||
//! | Platform | UPnP-IGD | NAT-PMP | PCP | TCP for HTTP |
|
||||
//! |---|---|---|---|---|
|
||||
//! | Linux/macOS/Windows | ✓ | ✓ | ✓ | ✓ |
|
||||
//! | Android (WiFi/Ethernet) | ✓ (with MulticastLock) | ✓ | ✓ | ✓ |
|
||||
//! | Android (cellular) | ✗ (skipped early) | ✗ | ✗ | ✗ |
|
||||
//! | iOS | ✗ (without `com.apple.developer.networking.multicast` entitlement) | ✓ | ✓ | ✓ |
|
||||
//!
|
||||
//! ## Android specifics
|
||||
//! `try_udp` / `try_tcp` first check `crate::android_wifi::is_on_wifi()`.
|
||||
//! If not on WiFi/Ethernet (cellular), returns `None` immediately — cellular
|
||||
//! networks almost never expose any of these protocols, and a discovery
|
||||
//! timeout would waste ~3s every startup.
|
||||
//!
|
||||
//! When on WiFi, a `WifiManager.MulticastLock` is acquired and held for the
|
||||
//! lifetime of the resulting `PortMapping`. Without the lock Android filters
|
||||
//! the SSDP multicast responses; PCP/NAT-PMP work without it but UPnP-IGD
|
||||
//! would never complete. The lock is released on Drop.
|
||||
//!
|
||||
//! ## iOS specifics
|
||||
//! Until Apple grants the multicast entitlement, UPnP-IGD will silently fail
|
||||
//! (no SSDP responses delivered to the socket). The unicast protocols PCP
|
||||
//! and NAT-PMP succeed without any entitlement and cover most modern home
|
||||
//! routers, so iOS gets reasonable coverage today.
|
||||
//!
|
||||
//! ## Renewal model
|
||||
//! Auto-renewal happens inside `portmapper::Client`'s internal task — there
|
||||
//! is **no** external renewal cycle to schedule. The `PortMapping` value
|
||||
//! owns the client; while it's held, the mapping stays alive. Dropping it
|
||||
//! aborts the renewal task (via `AbortOnDropHandle`) and stops keeping the
|
||||
//! mapping alive at the gateway. `release(&self)` triggers an explicit
|
||||
//! deactivation message before drop for cleaner shutdown.
|
||||
//!
|
||||
//! ## Anchor reachability watcher (bidirectional)
|
||||
//! `Network::start` spawns a task that observes the UDP mapping's
|
||||
//! `watch_external()` channel and adjusts anchor mode at runtime:
|
||||
//! - **Mapping lost** for >5 min → clear `is_anchor`. The node stops
|
||||
//! advertising itself as an anchor at the now-stale external address.
|
||||
//! - **Mapping restored** (None → Some) → re-evaluate auto-anchor. On
|
||||
//! non-mobile devices the anchor flag is set back on so the node
|
||||
//! re-joins the anchor set without a restart.
|
||||
//!
|
||||
//! Network roams (e.g., leaving a UPnP-capable WiFi and joining a new one)
|
||||
//! self-heal. Mobile devices never auto-anchor regardless — cellular IPs
|
||||
//! look public but sit behind CGNAT.
|
||||
//! Best-effort UPnP port mapping for NAT traversal.
|
||||
//! Skipped entirely on mobile platforms where UPnP is unsupported.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::num::NonZeroU16;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
use tracing::{info, debug};
|
||||
|
||||
/// An active port mapping. While this value is held, the underlying
|
||||
/// `portmapper::Client` keeps the lease alive in a background task.
|
||||
/// Dropping it releases the mapping.
|
||||
pub struct PortMapping {
|
||||
/// Result of a successful UPnP port mapping.
|
||||
pub struct UpnpMapping {
|
||||
pub external_addr: SocketAddr,
|
||||
pub lease_secs: u32,
|
||||
pub local_port: u16,
|
||||
#[allow(dead_code)] // service kept alive by holding the client
|
||||
client: portmapper::Client,
|
||||
#[cfg(target_os = "android")]
|
||||
#[allow(dead_code)] // lock kept alive for the lifetime of the mapping
|
||||
mcast_lock: Option<crate::android_wifi::MulticastLockGuard>,
|
||||
}
|
||||
|
||||
impl PortMapping {
|
||||
/// Best-effort UDP port mapping for the given local QUIC port.
|
||||
/// On Android, requires an active WiFi/Ethernet connection — returns
|
||||
/// `None` immediately on cellular. Waits up to 3 seconds for any of the
|
||||
/// three protocols (PCP / NAT-PMP / UPnP-IGD) to return a mapping.
|
||||
pub async fn try_udp(local_port: u16) -> Option<Self> {
|
||||
Self::try_for_protocol(local_port, portmapper::Protocol::Udp).await
|
||||
}
|
||||
/// Best-effort UPnP port mapping.
|
||||
/// 3s gateway discovery timeout, 1800s (30 min) lease, UDP protocol.
|
||||
/// Returns None on any failure (no router, unsupported, timeout, port conflict).
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub async fn try_upnp_mapping(local_port: u16) -> Option<UpnpMapping> {
|
||||
use igd_next::SearchOptions;
|
||||
|
||||
/// Best-effort TCP port mapping for HTTP serving on the given local port.
|
||||
/// Same platform gating as UDP. Used to make this node HTTP-reachable
|
||||
/// for browser-shaped fetches of public posts.
|
||||
pub async fn try_tcp(local_port: u16) -> Option<Self> {
|
||||
Self::try_for_protocol(local_port, portmapper::Protocol::Tcp).await
|
||||
}
|
||||
let search_opts = SearchOptions {
|
||||
timeout: Some(std::time::Duration::from_secs(3)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
async fn try_for_protocol(local_port: u16, protocol: portmapper::Protocol) -> Option<Self> {
|
||||
let port = NonZeroU16::new(local_port)?;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
if !crate::android_wifi::is_on_wifi() {
|
||||
debug!("Port mapping: skipping (not on WiFi/Ethernet)");
|
||||
return None;
|
||||
}
|
||||
let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await {
|
||||
Ok(gw) => gw,
|
||||
Err(e) => {
|
||||
debug!("UPnP gateway discovery failed (expected behind non-UPnP router): {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
let mcast_lock = crate::android_wifi::MulticastLockGuard::acquire("itsgoin-ssdp");
|
||||
let external_ip = match gateway.get_external_ip().await {
|
||||
Ok(ip) => ip,
|
||||
Err(e) => {
|
||||
debug!("UPnP: could not get external IP: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let config = portmapper::Config {
|
||||
enable_upnp: true,
|
||||
enable_pcp: true,
|
||||
enable_nat_pmp: true,
|
||||
protocol,
|
||||
};
|
||||
let client = portmapper::Client::new(config);
|
||||
client.update_local_port(port);
|
||||
// Local address for the mapping — bind to all interfaces
|
||||
let local_addr = SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), local_port);
|
||||
let lease_secs: u32 = 1800; // 30 minutes
|
||||
|
||||
// Wait up to 3 seconds for any protocol to produce a mapping.
|
||||
let mut watch = client.watch_external_address();
|
||||
let external = match tokio::time::timeout(Duration::from_secs(3), async {
|
||||
loop {
|
||||
if let Some(addr) = *watch.borrow_and_update() {
|
||||
return Some(addr);
|
||||
}
|
||||
if watch.changed().await.is_err() {
|
||||
// Try mapping the same external port first
|
||||
let result = gateway.add_port(
|
||||
igd_next::PortMappingProtocol::UDP,
|
||||
local_port,
|
||||
local_addr,
|
||||
lease_secs,
|
||||
"itsgoin",
|
||||
).await;
|
||||
|
||||
let external_port = match result {
|
||||
Ok(()) => local_port,
|
||||
Err(_) => {
|
||||
// Port taken — try any available port
|
||||
match gateway.add_any_port(
|
||||
igd_next::PortMappingProtocol::UDP,
|
||||
local_addr,
|
||||
lease_secs,
|
||||
"itsgoin",
|
||||
).await {
|
||||
Ok(port) => port,
|
||||
Err(e) => {
|
||||
debug!("UPnP: port mapping failed: {}", e);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Some(addr)) => addr,
|
||||
_ => {
|
||||
debug!(
|
||||
protocol = ?protocol,
|
||||
local_port,
|
||||
"Port mapping: no protocol responded within 3s (no UPnP/PCP/NAT-PMP gateway?)"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
external = %external,
|
||||
local_port,
|
||||
protocol = ?protocol,
|
||||
"Port mapping established"
|
||||
);
|
||||
let external_addr = SocketAddr::new(external_ip, external_port);
|
||||
info!("UPnP: mapped {}:{} → :{}", external_ip, external_port, local_port);
|
||||
|
||||
Some(PortMapping {
|
||||
external_addr: SocketAddr::V4(external),
|
||||
local_port,
|
||||
client,
|
||||
#[cfg(target_os = "android")]
|
||||
mcast_lock,
|
||||
})
|
||||
}
|
||||
Some(UpnpMapping {
|
||||
external_addr,
|
||||
lease_secs,
|
||||
local_port,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
pub async fn try_upnp_mapping(_local_port: u16) -> Option<UpnpMapping> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 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();
|
||||
/// Renew an existing UPnP lease. Returns true on success.
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub async fn renew_upnp_mapping(local_port: u16, external_port: u16) -> bool {
|
||||
use igd_next::SearchOptions;
|
||||
|
||||
let search_opts = SearchOptions {
|
||||
timeout: Some(std::time::Duration::from_secs(3)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await {
|
||||
Ok(gw) => gw,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let local_addr = SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), local_port);
|
||||
gateway.add_port(
|
||||
igd_next::PortMappingProtocol::UDP,
|
||||
external_port,
|
||||
local_addr,
|
||||
1800,
|
||||
"itsgoin",
|
||||
).await.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
pub async fn renew_upnp_mapping(_local_port: u16, _external_port: u16) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Remove UPnP mapping on shutdown. Best-effort, errors are silently ignored.
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub async fn remove_upnp_mapping(external_port: u16) {
|
||||
use igd_next::SearchOptions;
|
||||
|
||||
let search_opts = SearchOptions {
|
||||
timeout: Some(std::time::Duration::from_secs(3)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if let Ok(gateway) = igd_next::aio::tokio::search_gateway(search_opts).await {
|
||||
let _ = gateway.remove_port(igd_next::PortMappingProtocol::UDP, external_port).await;
|
||||
info!("UPnP: removed port mapping for external port {}", external_port);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
pub async fn remove_upnp_mapping(_external_port: u16) {}
|
||||
|
||||
// --- TCP port mapping (for HTTP post delivery) ---
|
||||
|
||||
/// Best-effort UPnP TCP port mapping on the same port as QUIC UDP.
|
||||
/// Returns true on success. Reuses the already-discovered gateway.
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub async fn try_upnp_tcp_mapping(local_port: u16, external_port: u16) -> bool {
|
||||
use igd_next::SearchOptions;
|
||||
|
||||
let search_opts = SearchOptions {
|
||||
timeout: Some(std::time::Duration::from_secs(3)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await {
|
||||
Ok(gw) => gw,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let local_addr = SocketAddr::new(
|
||||
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
|
||||
local_port,
|
||||
);
|
||||
|
||||
match gateway
|
||||
.add_port(
|
||||
igd_next::PortMappingProtocol::TCP,
|
||||
external_port,
|
||||
local_addr,
|
||||
1800,
|
||||
"itsgoin-http",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
info!("UPnP: TCP port {} mapped for HTTP serving", external_port);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("UPnP: TCP port mapping failed (non-fatal): {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
pub async fn try_upnp_tcp_mapping(_local_port: u16, _external_port: u16) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Renew an existing UPnP TCP lease. Returns true on success.
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub async fn renew_upnp_tcp_mapping(local_port: u16, external_port: u16) -> bool {
|
||||
use igd_next::SearchOptions;
|
||||
|
||||
let search_opts = SearchOptions {
|
||||
timeout: Some(std::time::Duration::from_secs(3)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await {
|
||||
Ok(gw) => gw,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let local_addr = SocketAddr::new(
|
||||
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
|
||||
local_port,
|
||||
);
|
||||
gateway
|
||||
.add_port(
|
||||
igd_next::PortMappingProtocol::TCP,
|
||||
external_port,
|
||||
local_addr,
|
||||
1800,
|
||||
"itsgoin-http",
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
pub async fn renew_upnp_tcp_mapping(_local_port: u16, _external_port: u16) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Remove UPnP TCP mapping on shutdown.
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub async fn remove_upnp_tcp_mapping(external_port: u16) {
|
||||
use igd_next::SearchOptions;
|
||||
|
||||
let search_opts = SearchOptions {
|
||||
timeout: Some(std::time::Duration::from_secs(3)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if let Ok(gateway) = igd_next::aio::tokio::search_gateway(search_opts).await {
|
||||
let _ = gateway
|
||||
.remove_port(igd_next::PortMappingProtocol::TCP, external_port)
|
||||
.await;
|
||||
info!("UPnP: removed TCP port mapping for port {}", external_port);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
pub async fn remove_upnp_tcp_mapping(_external_port: u16) {}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "itsgoin-desktop"
|
||||
version = "0.7.3"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import android.app.NotificationChannel
|
|||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
|
|
@ -17,17 +16,6 @@ class NodeService : Service() {
|
|||
companion object {
|
||||
const val CHANNEL_ID = "itsgoin_node"
|
||||
const val NOTIFICATION_ID = 1
|
||||
|
||||
// Called via JNI from Rust when the user taps the in-app close
|
||||
// button. Foreground services survive Activity exit by design
|
||||
// (keeps connections alive when backgrounded). When the user
|
||||
// explicitly wants to stop networking, we need to stop the
|
||||
// service in addition to ending the Activity.
|
||||
@JvmStatic
|
||||
fun stopFromNative(context: Context) {
|
||||
val intent = Intent(context, NodeService::class.java)
|
||||
context.stopService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
|
|
|
|||
|
|
@ -1142,18 +1142,6 @@ async fn list_vouches_given(state: State<'_, AppNode>) -> Result<Vec<VouchGivenD
|
|||
}).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn exit_app(app: tauri::AppHandle) {
|
||||
// On Android, the foreground NodeService survives Activity exit by
|
||||
// design (keeps network alive when backgrounded). When the user
|
||||
// explicitly hits the in-app close button, also stop the service
|
||||
// so we actually free the device's network/wakelock.
|
||||
#[cfg(target_os = "android")]
|
||||
itsgoin_core::android_wifi::stop_node_service();
|
||||
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_vouches_received(state: State<'_, AppNode>) -> Result<Vec<VouchReceivedDto>, String> {
|
||||
let node = get_node(&state).await;
|
||||
|
|
@ -1710,25 +1698,6 @@ async fn set_update_channel(state: State<'_, AppNode>, channel: String) -> Resul
|
|||
storage.set_setting("ui_update_channel", &channel).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_session_relay_enabled(state: State<'_, AppNode>) -> Result<bool, String> {
|
||||
let node = get_node(&state).await;
|
||||
Ok(node.network.conn_handle().is_session_relay_enabled().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_session_relay_enabled(state: State<'_, AppNode>, enabled: bool) -> Result<(), String> {
|
||||
let node = get_node(&state).await;
|
||||
{
|
||||
let storage = node.storage.get().await;
|
||||
storage
|
||||
.set_setting("relay.session_relay_enabled", if enabled { "true" } else { "false" })
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
node.network.conn_handle().set_session_relay_enabled(enabled).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open a URL in the user's default system browser.
|
||||
/// Desktop: spawns the platform opener (xdg-open / open / cmd start).
|
||||
/// Only https:// URLs are accepted to avoid being a generic command exec.
|
||||
|
|
@ -3411,9 +3380,6 @@ pub fn run() {
|
|||
import_as_new_identity,
|
||||
import_as_personas_cmd,
|
||||
import_merge_with_key,
|
||||
exit_app,
|
||||
get_session_relay_enabled,
|
||||
set_session_relay_enabled,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"productName": "itsgoin",
|
||||
"version": "0.7.3",
|
||||
"version": "0.7.0",
|
||||
"identifier": "com.itsgoin.app",
|
||||
"build": {
|
||||
"frontendDist": "../../frontend",
|
||||
|
|
|
|||
29
deploy.sh
29
deploy.sh
|
|
@ -21,20 +21,23 @@ KS_ALIAS="itsgoin"
|
|||
VERSION=$(grep '"version"' crates/tauri-app/tauri.conf.json | head -1 | sed 's/.*"\([0-9.]*\)".*/\1/')
|
||||
echo "=== Deploying v${VERSION} ==="
|
||||
|
||||
# Builds run SERIALLY — parallel cargo invocations write to the same
|
||||
# target/ directory, which causes intermittent failures (linuxdeploy
|
||||
# blowing up mid-AppImage was the v0.7.0 release symptom). The extra
|
||||
# wall time vs. the parallel version is small because cargo's
|
||||
# incremental cache deduplicates the shared core crate compilation.
|
||||
|
||||
echo "=== Building AppImage (includes GStreamer patch) ==="
|
||||
./build-appimage.sh
|
||||
|
||||
echo "=== Building APK ==="
|
||||
cargo tauri android build --apk
|
||||
|
||||
# Build CLI
|
||||
echo "=== Building CLI ==="
|
||||
cargo build -p itsgoin-cli --release
|
||||
cargo build -p itsgoin-cli --release &
|
||||
CLI_PID=$!
|
||||
|
||||
# Build APK
|
||||
echo "=== Building APK ==="
|
||||
cargo tauri android build --apk &
|
||||
APK_PID=$!
|
||||
|
||||
# Build AppImage (includes GStreamer patch)
|
||||
echo "=== Building AppImage ==="
|
||||
./build-appimage.sh
|
||||
wait $CLI_PID
|
||||
echo "=== CLI build complete ==="
|
||||
wait $APK_PID
|
||||
echo "=== APK build complete ==="
|
||||
|
||||
# Sign APK
|
||||
echo "=== Signing APK ==="
|
||||
|
|
|
|||
166
frontend/app.js
166
frontend/app.js
|
|
@ -449,12 +449,6 @@ $('#popover-overlay').addEventListener('click', (e) => {
|
|||
if (e.target === $('#popover-overlay')) closePopover();
|
||||
});
|
||||
|
||||
$('#close-app-btn').addEventListener('click', async () => {
|
||||
if (confirm('Close ItsGoin?\n\nStops all network connections to save battery. Reopen the app any time to resume.')) {
|
||||
try { await invoke('exit_app'); } catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
function relativeTime(timestampMs) {
|
||||
const now = Date.now();
|
||||
const diff = now - timestampMs;
|
||||
|
|
@ -1693,13 +1687,12 @@ async function openBioModal(nodeId, preloadedName) {
|
|||
${bio ? `<p style="font-size:0.9rem;line-height:1.45;margin:0 0 0.75rem">${escapeHtml(bio)}</p>` : '<p class="empty-hint" style="margin:0 0 0.75rem">No bio.</p>'}
|
||||
<div style="display:flex;gap:0.4rem;flex-wrap:wrap">
|
||||
<button id="bio-view-posts" class="btn btn-primary btn-sm">View Posts</button>
|
||||
${(following && isVouched)
|
||||
? `<button id="bio-unfriend" class="btn btn-ghost btn-sm">Unfriend</button>`
|
||||
: (following
|
||||
? `<button id="bio-vouch" class="btn btn-primary btn-sm">Add Vouch</button>
|
||||
<button id="bio-unfollow" class="btn btn-ghost btn-sm">Unfollow</button>`
|
||||
: `<button id="bio-friend" class="btn btn-primary btn-sm">Friend</button>
|
||||
<button id="bio-follow" class="btn btn-ghost btn-sm">Follow only</button>`)}
|
||||
${following
|
||||
? `<button id="bio-unfollow" class="btn btn-ghost btn-sm">Unfollow</button>`
|
||||
: `<button id="bio-follow" class="btn btn-primary btn-sm">Follow</button>`}
|
||||
${isVouched
|
||||
? `<button id="bio-revoke-vouch" class="btn btn-ghost btn-sm">Revoke Vouch</button>`
|
||||
: `<button id="bio-vouch" class="btn btn-ghost btn-sm">Vouch</button>`}
|
||||
<button id="bio-message" class="btn btn-ghost btn-sm">Message</button>
|
||||
${isIgnored
|
||||
? `<button id="bio-unignore" class="btn btn-ghost btn-sm">Unignore</button>`
|
||||
|
|
@ -1756,34 +1749,16 @@ async function openBioModal(nodeId, preloadedName) {
|
|||
} catch (e) { toast('Error: ' + e); }
|
||||
finally { vouch.disabled = false; }
|
||||
};
|
||||
// Friend = follow + vouch in one click. Default action per v0.7.x UX.
|
||||
const friend = document.getElementById('bio-friend');
|
||||
if (friend) friend.onclick = async () => {
|
||||
friend.disabled = true;
|
||||
try {
|
||||
await invoke('follow_node', { nodeIdHex: nodeId });
|
||||
await invoke('vouch_for_peer', { nodeIdHex: nodeId });
|
||||
toast(`Friended ${name}`);
|
||||
close();
|
||||
loadFollows();
|
||||
loadFeed(true);
|
||||
} catch (e) { toast('Error: ' + e); }
|
||||
finally { friend.disabled = false; }
|
||||
};
|
||||
// Unfriend = revoke vouch + unfollow. Rotation cost is real; confirm.
|
||||
const unfriend = document.getElementById('bio-unfriend');
|
||||
if (unfriend) unfriend.onclick = async () => {
|
||||
if (!confirm(`Unfriend ${name}? This revokes your vouch (rotates your vouch key — they keep access to existing posts but not future ones) AND unfollows them.`)) return;
|
||||
unfriend.disabled = true;
|
||||
const revokeVouch = document.getElementById('bio-revoke-vouch');
|
||||
if (revokeVouch) revokeVouch.onclick = async () => {
|
||||
if (!confirm(`Revoke vouch for ${name}? This rotates your vouch key — they keep access to existing posts but not future ones.`)) return;
|
||||
revokeVouch.disabled = true;
|
||||
try {
|
||||
await invoke('revoke_vouch_for_peer', { nodeIdHex: nodeId });
|
||||
await invoke('unfollow_node', { nodeIdHex: nodeId });
|
||||
toast(`Unfriended ${name}`);
|
||||
toast('Revoked and rotated');
|
||||
close();
|
||||
loadFollows();
|
||||
loadFeed(true);
|
||||
} catch (e) { toast('Error: ' + e); }
|
||||
finally { unfriend.disabled = false; }
|
||||
finally { revokeVouch.disabled = false; }
|
||||
};
|
||||
} catch (e) {
|
||||
bodyEl.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
||||
|
|
@ -2668,7 +2643,7 @@ async function doPost() {
|
|||
try {
|
||||
const vis = visibilitySelect.value;
|
||||
const params = { content: content || '' };
|
||||
if (vis !== 'public' && vis !== 'fof_closed') {
|
||||
if (vis !== 'public') {
|
||||
params.visibility = vis;
|
||||
}
|
||||
if (vis === 'circle') {
|
||||
|
|
@ -2692,22 +2667,14 @@ async function doPost() {
|
|||
const reactPerm = document.getElementById('react-perm-select').value;
|
||||
|
||||
let result;
|
||||
if (vis === 'fof_closed') {
|
||||
// Visibility = Extended Friends (FoF). Body + comments are
|
||||
// encrypted under the FoF gating CEK. Mode 1.
|
||||
if (commentPerm === 'friends_of_friends') {
|
||||
// FoF Layer 2: body is still public (Mode 2) but the post
|
||||
// carries a fof_gating block built from the author's
|
||||
// keyring. Routed through a dedicated command because the
|
||||
// gating block is signed at publish time (can't be added
|
||||
// via SetPolicy after the fact).
|
||||
if (selectedFiles.length > 0 || params.postingIdHex) {
|
||||
toast('FoF (Extended Friends) posts with attachments or non-default persona not yet supported.');
|
||||
postBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const created = await invoke('create_post_fof_closed', {
|
||||
content: params.content,
|
||||
});
|
||||
result = { id: created.postId };
|
||||
} else if (vis === 'public' && commentPerm === 'friends_of_friends') {
|
||||
// Public body, FoF-gated comments. Mode 2.
|
||||
if (selectedFiles.length > 0 || params.postingIdHex) {
|
||||
toast('FoF-comment posts with attachments or non-default persona not yet supported.');
|
||||
toast('FoF posts with attachments or non-default persona not yet supported.');
|
||||
postBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
|
@ -2715,6 +2682,20 @@ async function doPost() {
|
|||
content: params.content,
|
||||
});
|
||||
result = { id: created.postId };
|
||||
} else if (commentPerm === 'fof_closed') {
|
||||
// FoF Layer 3 / Mode 1: body itself encrypted under the
|
||||
// gating CEK. Non-FoF observers see only ciphertext;
|
||||
// FoF readers unlock + decrypt on render via
|
||||
// read_fof_closed_body.
|
||||
if (selectedFiles.length > 0 || params.postingIdHex) {
|
||||
toast('FoFClosed posts with attachments or non-default persona not yet supported.');
|
||||
postBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const created = await invoke('create_post_fof_closed', {
|
||||
content: params.content,
|
||||
});
|
||||
result = { id: created.postId };
|
||||
} else if (selectedFiles.length > 0) {
|
||||
// Convert ArrayBuffers to base64 strings
|
||||
const files = selectedFiles.map(f => {
|
||||
|
|
@ -2747,7 +2728,7 @@ async function doPost() {
|
|||
selectedFiles = [];
|
||||
renderAttachmentPreview();
|
||||
updateCharCount();
|
||||
visibilitySelect.value = 'fof_closed';
|
||||
visibilitySelect.value = 'public';
|
||||
updateVisibilityUI();
|
||||
toast('Posted!');
|
||||
loadFeed(true);
|
||||
|
|
@ -3001,11 +2982,6 @@ async function loadCircleProfiles() {
|
|||
function updateVisibilityUI() {
|
||||
const vis = visibilitySelect.value;
|
||||
circleSelect.classList.toggle('hidden', vis !== 'circle');
|
||||
// Hide the comment-permission picker for FoF (Extended Friends) — the
|
||||
// visibility already implies comments-restricted-to-FoF. Show it
|
||||
// again when audience is public / friends / circle.
|
||||
const commentPerm = document.getElementById('comment-perm-select');
|
||||
if (commentPerm) commentPerm.classList.toggle('hidden', vis === 'fof_closed');
|
||||
}
|
||||
|
||||
async function loadCircleOptions() {
|
||||
|
|
@ -3023,9 +2999,6 @@ visibilitySelect.addEventListener('change', () => {
|
|||
updateVisibilityUI();
|
||||
if (visibilitySelect.value === 'circle') loadCircleOptions();
|
||||
});
|
||||
// Run once on load so the comment-perm picker is hidden for the
|
||||
// default FoF visibility (matches the dropdown's `selected` option).
|
||||
updateVisibilityUI();
|
||||
|
||||
// --- Circles management ---
|
||||
async function loadCircles() {
|
||||
|
|
@ -3549,11 +3522,11 @@ if (exportKeyBtn) exportKeyBtn.addEventListener('click', async () => {
|
|||
const key = await invoke('export_identity');
|
||||
try {
|
||||
await navigator.clipboard.writeText(key);
|
||||
toast('Device address key copied to clipboard. KEEP IT SECRET!');
|
||||
toast('Identity key copied to clipboard. KEEP IT SECRET!');
|
||||
} catch (clipErr) {
|
||||
// Clipboard API may fail in some webview contexts — show the key instead
|
||||
console.error('Clipboard write failed:', clipErr);
|
||||
prompt('Copy your device address key (KEEP IT SECRET!):', key);
|
||||
prompt('Copy your identity key (KEEP IT SECRET!):', key);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('export_identity failed:', e);
|
||||
|
|
@ -3592,16 +3565,14 @@ document.querySelectorAll('.text-size-opt').forEach(btn => {
|
|||
});
|
||||
});
|
||||
|
||||
// --- Device address management (formerly "Identity") ---
|
||||
// The underlying Tauri commands keep their `identity` names for now —
|
||||
// only the user-facing labels rename. Backend rename can follow.
|
||||
// --- Identity management ---
|
||||
async function loadIdentities() {
|
||||
const list = $('#identities-list');
|
||||
if (!list) return;
|
||||
try {
|
||||
const identities = await invoke('list_identities');
|
||||
if (identities.length === 0) {
|
||||
list.innerHTML = '<p class="empty-hint">No device addresses</p>';
|
||||
list.innerHTML = '<p class="empty-hint">No identities</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = identities.map(id => {
|
||||
|
|
@ -3624,7 +3595,7 @@ async function loadIdentities() {
|
|||
btn.textContent = 'Switching...';
|
||||
try {
|
||||
await invoke('switch_identity', { nodeIdHex: btn.dataset.id });
|
||||
toast('Device address switched — reloading...');
|
||||
toast('Identity switched — reloading...');
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} catch (e) { toast('Error: ' + e); btn.disabled = false; btn.textContent = 'Switch'; }
|
||||
});
|
||||
|
|
@ -3633,10 +3604,10 @@ async function loadIdentities() {
|
|||
// Wire delete buttons
|
||||
list.querySelectorAll('.delete-id-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm('Delete this device address? This cannot be undone.')) return;
|
||||
if (!confirm('Delete this identity? This cannot be undone.')) return;
|
||||
try {
|
||||
await invoke('delete_identity', { nodeIdHex: btn.dataset.id });
|
||||
toast('Device address deleted');
|
||||
toast('Identity deleted');
|
||||
loadIdentities();
|
||||
} catch (e) { toast('Error: ' + e); }
|
||||
});
|
||||
|
|
@ -3652,9 +3623,8 @@ $('#create-identity-btn').addEventListener('click', () => {
|
|||
overlay.style.cursor = 'default';
|
||||
overlay.innerHTML = `
|
||||
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:350px;width:90%;text-align:center">
|
||||
<h3 style="color:#7fdbca;margin:0 0 0.5rem">New Device Address</h3>
|
||||
<p style="font-size:0.72rem;color:#888;margin-bottom:0.75rem">A new QUIC network endpoint for this device. Your personas are unaffected.</p>
|
||||
<input id="new-id-name" type="text" placeholder="Label (your reference, not shared)" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
|
||||
<h3 style="color:#7fdbca;margin:0 0 0.75rem">New Identity</h3>
|
||||
<input id="new-id-name" type="text" placeholder="Display name" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
|
||||
<div style="display:flex;gap:0.5rem;justify-content:center">
|
||||
<button class="btn btn-primary btn-sm" id="new-id-create">Create</button>
|
||||
<button class="btn btn-ghost btn-sm" id="new-id-cancel">Cancel</button>
|
||||
|
|
@ -3663,10 +3633,10 @@ $('#create-identity-btn').addEventListener('click', () => {
|
|||
document.body.appendChild(overlay);
|
||||
overlay.querySelector('#new-id-create').addEventListener('click', async () => {
|
||||
const name = overlay.querySelector('#new-id-name').value.trim();
|
||||
if (!name) { toast('Label is required'); return; }
|
||||
if (!name) { toast('Name is required'); return; }
|
||||
try {
|
||||
const nodeId = await invoke('create_identity', { name });
|
||||
toast(`Device address created: ${nodeId.substring(0, 12)}`);
|
||||
toast(`Identity created: ${nodeId.substring(0, 12)}`);
|
||||
overlay.remove();
|
||||
loadIdentities();
|
||||
} catch (e) { toast('Error: ' + e); }
|
||||
|
|
@ -3681,10 +3651,10 @@ $('#import-identity-btn').addEventListener('click', () => {
|
|||
overlay.style.cursor = 'default';
|
||||
overlay.innerHTML = `
|
||||
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:400px;width:90%;text-align:center">
|
||||
<h3 style="color:#7fdbca;margin:0 0 0.5rem">Import Device Address Key</h3>
|
||||
<p style="font-size:0.75rem;color:#888;margin-bottom:0.5rem">Paste a 64-character hex key from a previous device-address export. This is NOT how you move your personas — use Export/Import personas above for that.</p>
|
||||
<input id="import-id-key" type="text" placeholder="Device address key (64 hex chars)" maxlength="64" style="width:100%;margin-bottom:0.5rem;font-family:monospace;font-size:0.7rem" />
|
||||
<input id="import-id-name" type="text" placeholder="Label (your reference, not shared)" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
|
||||
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Import Identity</h3>
|
||||
<p style="font-size:0.75rem;color:#888;margin-bottom:0.5rem">Paste the 64-character hex key from an identity export.</p>
|
||||
<input id="import-id-key" type="text" placeholder="Identity key (64 hex chars)" maxlength="64" style="width:100%;margin-bottom:0.5rem;font-family:monospace;font-size:0.7rem" />
|
||||
<input id="import-id-name" type="text" placeholder="Display name" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
|
||||
<div style="display:flex;gap:0.5rem;justify-content:center">
|
||||
<button class="btn btn-primary btn-sm" id="import-id-go">Import</button>
|
||||
<button class="btn btn-ghost btn-sm" id="import-id-cancel">Cancel</button>
|
||||
|
|
@ -3697,7 +3667,7 @@ $('#import-identity-btn').addEventListener('click', () => {
|
|||
if (keyHex.length !== 64) { toast('Key must be 64 hex characters'); return; }
|
||||
try {
|
||||
const nodeId = await invoke('import_identity_key', { keyHex, name });
|
||||
toast(`Device address imported: ${nodeId.substring(0, 12)}`);
|
||||
toast(`Identity imported: ${nodeId.substring(0, 12)}`);
|
||||
overlay.remove();
|
||||
loadIdentities();
|
||||
} catch (e) { toast('Error: ' + e); }
|
||||
|
|
@ -4071,14 +4041,14 @@ $('#export-btn').addEventListener('click', () => {
|
|||
overlay.className = 'image-lightbox';
|
||||
overlay.style.cursor = 'default';
|
||||
overlay.innerHTML = `
|
||||
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:420px;width:90%;text-align:center">
|
||||
<h3 style="color:#7fdbca;margin:0 0 0.5rem">Export your personas</h3>
|
||||
<p style="font-size:0.75rem;color:#888;margin-bottom:0.75rem">Save your personas + (optionally) your posts to a ZIP file so you can import them on another device.</p>
|
||||
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:400px;width:90%;text-align:center">
|
||||
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Export Data</h3>
|
||||
<p style="font-size:0.75rem;color:#888;margin-bottom:0.75rem">Choose what to include in the export ZIP.</p>
|
||||
<div style="display:flex;flex-direction:column;gap:0.5rem;text-align:left;margin-bottom:1rem">
|
||||
<label class="checkbox-label"><input type="radio" name="export-scope" value="identity_only" /> Persona keys only (tiny backup — restores your identity but not your posts)</label>
|
||||
<label class="checkbox-label"><input type="radio" name="export-scope" value="posts_only" /> Posts + media only (no keys — safe to share publicly)</label>
|
||||
<label class="checkbox-label"><input type="radio" name="export-scope" value="posts_with_identity" checked /> Posts + media + persona keys (typical “move to new device”)</label>
|
||||
<label class="checkbox-label"><input type="radio" name="export-scope" value="everything" /> Everything (posts, keys, follows, settings)</label>
|
||||
<label class="checkbox-label"><input type="radio" name="export-scope" value="identity_only" /> Identity key only (tiny backup)</label>
|
||||
<label class="checkbox-label"><input type="radio" name="export-scope" value="posts_only" /> Posts + media (no key — safe to share)</label>
|
||||
<label class="checkbox-label"><input type="radio" name="export-scope" value="posts_with_identity" checked /> Posts + media + identity key</label>
|
||||
<label class="checkbox-label"><input type="radio" name="export-scope" value="everything" /> Everything (posts, key, follows, settings)</label>
|
||||
</div>
|
||||
<div style="margin-bottom:0.75rem">
|
||||
<label style="font-size:0.75rem;color:#888">Save to folder:</label>
|
||||
|
|
@ -4148,8 +4118,8 @@ $('#import-btn').addEventListener('click', () => {
|
|||
overlay.style.cursor = 'default';
|
||||
overlay.innerHTML = `
|
||||
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:420px;width:90%;text-align:center">
|
||||
<h3 style="color:#7fdbca;margin:0 0 0.5rem">Import from another device</h3>
|
||||
<p style="font-size:0.75rem;color:#888;margin-bottom:0.75rem">Select an ItsGoin export ZIP. Default action restores the exported personas onto this device so you can post as them.</p>
|
||||
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Import Data</h3>
|
||||
<p style="font-size:0.75rem;color:#888;margin-bottom:0.75rem">Select an ItsGoin export ZIP file.</p>
|
||||
<div style="display:flex;gap:0.25rem;margin-bottom:0.75rem">
|
||||
<input id="import-zip-path" type="text" placeholder="/path/to/itsgoin-export.zip" style="flex:1;font-size:0.8rem" />
|
||||
<button class="btn btn-ghost btn-sm" id="import-browse">Browse</button>
|
||||
|
|
@ -4251,20 +4221,6 @@ $('#import-btn').addEventListener('click', () => {
|
|||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||
});
|
||||
|
||||
(async () => {
|
||||
const toggle = $('#session-relay-toggle');
|
||||
if (!toggle) return;
|
||||
try { toggle.checked = await invoke('get_session_relay_enabled'); } catch (_) {}
|
||||
toggle.addEventListener('change', async () => {
|
||||
try {
|
||||
await invoke('set_session_relay_enabled', { enabled: toggle.checked });
|
||||
} catch (e) {
|
||||
toggle.checked = !toggle.checked;
|
||||
alert('Failed to update session relay setting: ' + e);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
$('#notifications-btn').addEventListener('click', async () => {
|
||||
// Load current settings
|
||||
const msgVal = await invoke('get_setting', { key: 'notif_messages' }).catch(() => null) || 'on';
|
||||
|
|
@ -4429,7 +4385,7 @@ async function init() {
|
|||
<p style="color:#888;font-size:0.85rem;margin-bottom:1.5rem">How would you like to get started?</p>
|
||||
<div style="display:flex;flex-direction:column;gap:0.75rem">
|
||||
<button id="first-run-new" class="btn btn-primary" style="padding:0.75rem">Start Fresh</button>
|
||||
<button id="first-run-import" class="btn btn-ghost" style="padding:0.75rem">Import from another device</button>
|
||||
<button id="first-run-import" class="btn btn-ghost" style="padding:0.75rem">Import an Identity</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(chooser);
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@
|
|||
<span id="net-dot"></span>
|
||||
<span id="net-labels"></span>
|
||||
</div>
|
||||
<button id="close-app-btn" title="Close app (stops connections to save battery)" aria-label="Close app"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg></button>
|
||||
</div>
|
||||
<nav id="tabs">
|
||||
<button class="tab" data-tab="feed"><span class="tab-icon">📰</span><span class="tab-label">Feed</span></button>
|
||||
|
|
@ -97,9 +96,8 @@
|
|||
<div id="visibility-row">
|
||||
<select id="persona-select" title="Post as" class="hidden"></select>
|
||||
<select id="visibility-select">
|
||||
<option value="fof_closed" selected>Extended Friends (FoF)</option>
|
||||
<option value="friends">Friends</option>
|
||||
<option value="public">Public</option>
|
||||
<option value="friends">Friends</option>
|
||||
<option value="circle">Circle</option>
|
||||
</select>
|
||||
<select id="circle-select" class="hidden"></select>
|
||||
|
|
@ -107,6 +105,7 @@
|
|||
<option value="public">Comments: All</option>
|
||||
<option value="followers_only">Comments: Followers</option>
|
||||
<option value="friends_of_friends">Comments: Friends of Friends</option>
|
||||
<option value="fof_closed">Body+Comments: FoF only (Mode 1)</option>
|
||||
<option value="none">Comments: Off</option>
|
||||
</select>
|
||||
<select id="react-perm-select" title="React permission">
|
||||
|
|
@ -232,13 +231,13 @@
|
|||
<button id="check-updates-btn" class="btn btn-ghost btn-sm" style="margin-top:0.5rem">Check now</button>
|
||||
</div>
|
||||
|
||||
<div class="section-card" style="text-align:left">
|
||||
<h3 style="margin-bottom:0.4rem;text-align:center">Your data on this device</h3>
|
||||
<p class="empty-hint" style="margin-bottom:0.5rem;font-size:0.78rem;line-height:1.5">
|
||||
<strong style="color:#7fdbca">Personas</strong> are who you are to peers — the keys you post and message with. Most people only need one. To move your account to a new device, you <em>export your personas</em> from this device and <em>import them</em> on the new one.
|
||||
<br><br>
|
||||
<strong style="color:#888">Device Address</strong> below is this device's own network endpoint — usually not what you want to move. Leave it alone unless you know why you're touching it.
|
||||
</p>
|
||||
<div class="section-card" style="text-align:center">
|
||||
<h3 style="margin-bottom:0.5rem">Identities</h3>
|
||||
<div id="identities-list" style="margin-bottom:0.5rem"></div>
|
||||
<div style="display:flex;gap:0.5rem;justify-content:center;flex-wrap:wrap">
|
||||
<button id="create-identity-btn" class="btn btn-ghost btn-sm">New Identity</button>
|
||||
<button id="import-identity-btn" class="btn btn-ghost btn-sm">Import Key</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card" style="text-align:center">
|
||||
|
|
@ -249,25 +248,9 @@
|
|||
</div>
|
||||
|
||||
<div class="section-card" style="text-align:center">
|
||||
<h3 style="margin-bottom:0.4rem">Move to another device</h3>
|
||||
<p class="empty-hint" style="margin-bottom:0.5rem;font-size:0.78rem">
|
||||
Export creates a ZIP containing your personas (and optionally posts/follows). Import on the other device's Settings > Move to another device.
|
||||
</p>
|
||||
<div style="display:flex;gap:0.5rem;justify-content:center;flex-wrap:wrap">
|
||||
<button id="export-btn" class="btn btn-primary btn-sm">Export personas</button>
|
||||
<button id="import-btn" class="btn btn-ghost btn-sm">Import from another device</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card" style="text-align:center">
|
||||
<h3 style="margin-bottom:0.4rem;font-size:0.85rem;color:#888">Device Address (advanced)</h3>
|
||||
<p class="empty-hint" style="margin-bottom:0.5rem;font-size:0.72rem">
|
||||
This device's network endpoint — the QUIC address peers use to reach you. Changing this rotates the device's network identifier but does NOT change your posting identity (personas). Rarely useful.
|
||||
</p>
|
||||
<div id="identities-list" style="margin-bottom:0.5rem"></div>
|
||||
<div style="display:flex;gap:0.5rem;justify-content:center;flex-wrap:wrap">
|
||||
<button id="create-identity-btn" class="btn btn-ghost btn-sm">New Device Address</button>
|
||||
<button id="import-identity-btn" class="btn btn-ghost btn-sm">Import Address Key</button>
|
||||
<button id="export-btn" class="btn btn-ghost btn-sm">Export</button>
|
||||
<button id="import-btn" class="btn btn-ghost btn-sm">Import</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -275,17 +258,6 @@
|
|||
<button id="notifications-btn" class="btn btn-ghost btn-full">Notifications</button>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<h3 style="margin-bottom:0.4rem;font-size:0.85rem;color:#888">Session Relay (off by default)</h3>
|
||||
<p class="empty-hint" style="margin-bottom:0.5rem;font-size:0.72rem">
|
||||
When two peers can't connect directly through their networks, a third peer can pipe their traffic through itself. This burns the relay peer's bandwidth on someone else's connection. Off by default. Enable only if you're OK both <em>using</em> other peers as relays and <em>serving</em> as a relay for others.
|
||||
</p>
|
||||
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer">
|
||||
<input type="checkbox" id="session-relay-toggle">
|
||||
<span>Enable session relay</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Hidden: node-info, anchors, diagnostics btn moved elsewhere -->
|
||||
<div class="hidden">
|
||||
<button id="anchors-toggle"></button>
|
||||
|
|
@ -328,7 +300,7 @@
|
|||
|
||||
<div class="section-card">
|
||||
<h3>Danger Zone</h3>
|
||||
<p class="empty-hint">Delete all local data. Device address key preserved.</p>
|
||||
<p class="empty-hint">Delete all local data. Identity key preserved.</p>
|
||||
<button id="reset-data-btn" class="btn btn-danger btn-full">Reset All Data</button>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -18,9 +18,6 @@ header h1 { font-size: clamp(1.4rem, 2.5vw, 2rem); color: #7fdbca; margin: 0; }
|
|||
#net-dot.net-green { background: #22c55e; }
|
||||
#net-labels { font-size: 0.65rem; color: #888; display: flex; gap: 0.3rem; }
|
||||
.net-label { background: #2a2a40; padding: 0.1rem 0.35rem; border-radius: 3px; color: #aab; }
|
||||
#close-app-btn { margin-left: 0.6rem; padding: 0.3rem 0.5rem; background: transparent; border: 1px solid #444; border-radius: 4px; color: #999; font-size: 1rem; line-height: 1; cursor: pointer; }
|
||||
#close-app-btn:hover { color: #e74c3c; border-color: #e74c3c; }
|
||||
#close-app-btn:active { background: #2a1a1a; }
|
||||
|
||||
/* Setup overlay */
|
||||
.overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(10, 10, 20, 0.92); display: flex; align-items: center; justify-content: center; z-index: 200; }
|
||||
|
|
@ -227,8 +224,7 @@ header h1 { font-size: clamp(1.4rem, 2.5vw, 2rem); color: #7fdbca; margin: 0; }
|
|||
.identicon { display: inline-block; vertical-align: middle; flex-shrink: 0; border-radius: 2px; }
|
||||
|
||||
/* Visibility selector */
|
||||
#visibility-row { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.35rem; align-items: center; max-width: 100%; }
|
||||
#visibility-row select { max-width: 100%; }
|
||||
#visibility-row { display: flex; gap: 0.4rem; margin-top: 0.35rem; align-items: center; }
|
||||
#visibility-select, #circle-select { background: #1a1a2e; border: 1px solid #444; border-radius: 3px; padding: 0.2rem 0.4rem; font-size: 0.75rem; font-family: inherit; -webkit-appearance: none; appearance: none; }
|
||||
#visibility-select:focus, #circle-select:focus { outline: none; border-color: #7fdbca; }
|
||||
#visibility-select option, #circle-select option { background: #fff; color: #000; }
|
||||
|
|
|
|||
68
sessions.md
68
sessions.md
|
|
@ -6,74 +6,6 @@ See `CONTRIBUTING.md` for the protocol. See `AGENTS.md` for the Claude-specific
|
|||
|
||||
---
|
||||
|
||||
## 2026-05-13 to 2026-05-15 — primary Claude (Lead) — `docs/fof-spec-layer1-bio-grants` → master
|
||||
|
||||
**Started**: May 13 UTC. Released v0.7.0 stable on May 15 UTC.
|
||||
**Instance**: Scott's primary Claude (Lead)
|
||||
**Issue**: implement FoF spec Layers 1–5 end-to-end
|
||||
**Branch**: `docs/fof-spec-layer1-bio-grants` (continued from prior spec-drafting session; merged to master at d46fcb4 on May 15)
|
||||
**Scope**: Friend-of-Friend post gating: per-persona vouch keys, anonymous bio-post wrapper distribution, FoF-gated comments with CDN verification, FoF-closed encrypted bodies, V_me lifecycle (rotation/cascade/key-burn), unlock cache + retry sweep. Pre-deploy hardening pass. Version bump to 0.7.0 stable.
|
||||
|
||||
**Commits landed on master** (34 total, `1fdf9a9..d46fcb4`):
|
||||
|
||||
Layer 1 (vouch primitive):
|
||||
- `8a53d83` schema + storage API + HPKE-sealed vouch-grant crypto + 3 tests
|
||||
- `bc008c5` wire types (VouchGrantBatch) + V_me auto-gen on persona create
|
||||
- `3ee5c30` publish path: bucketed-padding VouchGrantBatch in bio posts
|
||||
- `d1afcec` receive-path scan + follow-gating + scan cache (2 e2e tests)
|
||||
- `34c5b60` Tauri commands + Settings UI for vouching
|
||||
|
||||
Layer 2 (Mode 2 + CDN verify + revocation + access-grant):
|
||||
- `74fec3b` wrap-slot dual-derivation seal/open primitives + 4 tests
|
||||
- `0f5147a` wire types: WrapSlot, FoFCommentGating, CommentPermission::FriendsOfFriends, RevocationEntry
|
||||
- `bdcd214` fof.rs: build_fof_comment_gating with bucketed padding
|
||||
- `673f9e2` wired FoF gating into post-create path
|
||||
- `00522f4` reader unlock + commenter authoring + sig verify (1 roundtrip test)
|
||||
- `63ff5ad` CDN four-check verification on AddComment receive
|
||||
- `583033e` persist FoF fields + fof_revocations table
|
||||
- `6a76ade` FoFRevocation diff + sign/verify/apply + retroactive cascade-delete (2 tests)
|
||||
- `96118d7` FoFAccessGrant diff + retroactive read widening (1 test)
|
||||
- `10de3f6` Tauri commands + frontend compose-picker for Mode 2
|
||||
|
||||
Layer 3 (Mode 1 FoFClosed):
|
||||
- `856f386` PostVisibility::FoFClosed variant + body encrypt/decrypt + body-size bucket padding (3 tests)
|
||||
- `66b7804` create_post_fof_closed + read_fof_closed_body + frontend hooks for locked/unlocked posts (1 e2e test)
|
||||
|
||||
Layer 4 (V_me lifecycle + cascade + key-burn):
|
||||
- `c0de21d` own_post_slot_provenance + Node::rotate_v_me + cascade_revoke_v_me_epoch (1 test)
|
||||
- `c2f2203` FoFKeyBurn primitive (1 test)
|
||||
- `fdbf97f` supersedes_post_id field for re-issue path
|
||||
- `ce710a6` Tauri commands + Settings "Rotate my vouch key" UI
|
||||
|
||||
Layer 5 (perf):
|
||||
- `12a3058` unlock cache + unreadable-posts queue + author-direct fast path + sweep on V_x arrival (2 tests)
|
||||
|
||||
Pre-deploy hardening (audit pass):
|
||||
- `aa190db` wire-shape validation on incoming FoF posts; unreadable-queue per-persona cap of 4096 (7 tests)
|
||||
- `4ec3a80` key-burn replay rejection (monotonic timestamps); MAX_SWEEP_PER_CALL=256 (1 test)
|
||||
|
||||
Release prep:
|
||||
- `d46fcb4` version bump 0.6.2 → 0.7.0; download page updated with FoF release notes
|
||||
|
||||
**Test count**: 158 passing on master (added ~24 new fof:: integration tests across Layers 1–5 + hardening).
|
||||
|
||||
**Build state**: full-pipeline deploy initiated on May 15 (`./deploy.sh` from this Linux host: CLI + AppImage + APK in parallel, sign APK, sequential SCP uploads, anchor swap with signed release announcement). Windows installer separate (uploaded by Windows host team).
|
||||
|
||||
**Key design decisions worth knowing**:
|
||||
- `slot_binder_nonce` (32B random per post) replaces the spec's "post_id in HKDF info" — PostId = BLAKE3(post) was circular here. Same anti-replay property.
|
||||
- Per-V_x signing keypair (`pub_x`/`priv_x`) generated per-post (not per-V_x-genesis). Comment signing is asymmetric Ed25519; PQ-migration deferred. Body + comment-payload encryption is symmetric ChaCha20-Poly1305 (PQ-safe).
|
||||
- `vouch_keys_received` keyed by `(holder, owner, epoch)` — multi-epoch retention is the receiver-chain mechanism. New V_me from a voucher appends; old key isn't deleted.
|
||||
- Revocation is per-post per-pub_x with retroactive cascade-delete. V_me rotation is grandfather-by-default; cascade is opt-in via `cascade_revoke_v_me_epoch`. Key-burn swaps slots in-place for leaked-key scenarios.
|
||||
|
||||
**Pending after deploy succeeds**:
|
||||
- Live shakedown on real devices (Scott has been looking forward to this).
|
||||
- Per-post Revoke / Grant Access UI surfaces (Tauri commands exist; only Rotate has a Settings button so far).
|
||||
- Update `MEMORY.md` "Current Status" to v0.7.0 once anchor swap confirms healthy.
|
||||
|
||||
**Stopping point**: deploy script running in background; master at `d46fcb4`. Awaiting deploy completion notification.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-24 — primary Claude (Lead) — `docs/fof-spec-layer1-bio-grants`
|
||||
|
||||
**Started**: April 24 UTC
|
||||
|
|
|
|||
|
|
@ -474,9 +474,8 @@ FAILURE: C → B → A: AnchorProbeResult { reachable: false }</code></pre
|
|||
<h3>Session relay (relay pipes)</h3>
|
||||
<p>Intermediary splices bi-streams between requester and target. Desktop: max 10 concurrent pipes. Mobile: max 2. Each pipe has a 50MB byte cap and 2-min idle timeout.</p>
|
||||
<div class="note">
|
||||
<strong>v0.7.2 change</strong>: Session relay is now <strong>OPT-IN ONLY and DISABLED BY DEFAULT</strong> — including for anchor-mode nodes (servers are most likely to pay for bandwidth). Gated by the <code>relay.session_relay_enabled</code> setting on both <em>serving</em> (<code>can_accept_relay_pipe</code>) and <em>using</em> (the auto-fallback after a failed hole punch). Settings UI exposes the toggle. Hole-punch-failure no longer silently routes a peer-to-peer session through an unrelated third party's bandwidth.
|
||||
<strong>v0.2.0 change</strong>: Relay pipes are <strong>own-device-only by default</strong>. A node will only relay traffic between its own devices (same identity key, different device identity). Users can opt in to relaying for others in Settings, but this is not enabled automatically. This prevents nodes from unknowingly burning bandwidth for random peers while still enabling personal multi-device routing.
|
||||
</div>
|
||||
<p style="color: var(--text-muted); font-size: 0.85rem;">This rule covers <em>only</em> full byte-piping. Small relay-style signaling/discovery — <code>RelayIntroduce</code> for hole-punch coordination, <code>worm_lookup</code> multi-hop search, N1/N2/N3 share-list exchange — remains always-on; that's not session relay. The anchor's HTTP proxy path (anchor fetches a post via QUIC and serves it back over HTTP) is also not session relay — it's the anchor doing its own QUIC fetch on the browser's behalf.</p>
|
||||
|
||||
<h3>Deduplication & cooldowns</h3>
|
||||
<table>
|
||||
|
|
@ -517,15 +516,8 @@ FAILURE: C → B → A: AnchorProbeResult { reachable: false }</code></pre
|
|||
</table>
|
||||
<p>All hole punch paths use <code>hole_punch_with_scanning()</code> which replaces the former hard+hard skip. NAT profiles (NatMapping + NatFiltering) from InitialExchange determine whether scanning is attempted. Behavioral inference updates filtering classification from connection outcomes.</p>
|
||||
|
||||
<h3>Advanced NAT traversal (EDM port scanner)</h3>
|
||||
<h3>Status: <span class="badge badge-planned">Disabled in v0.7.3 — refactor pending</span></h3>
|
||||
|
||||
<div class="note">
|
||||
<strong>v0.7.3:</strong> the EDM port scanner is DISABLED. <code>hole_punch_with_scanning()</code> currently does only Step 1 (quick punch to the anchor-observed address) and Step 2 (parallel punch to all known addresses over a 30s window). No port scan.
|
||||
<p style="margin: 0.5rem 0 0 0;"><strong>Why:</strong> iroh's <code>Endpoint</code> accumulates every <code>endpoint.connect()</code> target into a per-endpoint paths set and probes them all in the background under QUIC NAT-traversal. A 100-probes/sec / 5-min scan inserted ~30,000 paths; iroh then probed all of them. Observed at 22MB/s outbound from a single client — DoS-grade.</p>
|
||||
<p style="margin: 0.5rem 0 0 0;"><strong>Refactor target:</strong> replace per-probe <code>endpoint.connect()</code> with raw <code>socket.send_to()</code> on the endpoint's bound UDP socket. The probe still opens a NAT mapping on our side; we just don't ask iroh to manage the path. The original scanner body is preserved as <code>edm_port_scan_disabled_v0_7_3</code> in <code>connection.rs</code>, including <code>PortWalkIter</code>, <code>scanner_semaphore</code>, role-based scanner/puncher split, and the <code>tokio::select!</code> orchestration — refactor against that.</p>
|
||||
<p style="margin: 0.5rem 0 0 0;">The description below documents the <em>intended design</em> the refactor will deliver against.</p>
|
||||
</div>
|
||||
<h3>Advanced NAT traversal</h3>
|
||||
<h3>Status: <span class="badge badge-complete">Complete</span></h3>
|
||||
|
||||
<p>NAT "hardness" has two independent dimensions:</p>
|
||||
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
|
||||
|
|
@ -580,44 +572,28 @@ FAILURE: C → B → A: AnchorProbeResult { reachable: false }</code></pre
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 11. Port Mapping (UPnP-IGD + NAT-PMP + PCP) -->
|
||||
<!-- 11. UPnP Port Mapping -->
|
||||
<section id="upnp">
|
||||
<h2>11. Port Mapping — UPnP-IGD + NAT-PMP + PCP</h2>
|
||||
<h3>Status: <span class="badge badge-complete">Complete (v0.7.2)</span></h3>
|
||||
<h2>11. UPnP Port Mapping</h2>
|
||||
<h3>Status: <span class="badge badge-complete">Complete</span></h3>
|
||||
|
||||
<h3>Purpose</h3>
|
||||
<p>Asks the local gateway router to forward an external port to this node's local QUIC port. A successful mapping makes the node <strong>directly reachable from the internet</strong> without hole punching — any peer with the external address can connect immediately. Three protocols are attempted in parallel; the first router-response wins.</p>
|
||||
|
||||
<h3>Protocols (v0.7.2)</h3>
|
||||
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
|
||||
<li><strong>UPnP-IGD</strong> — long-standing consumer-router default. Discovery via SSDP multicast on 239.255.255.250:1900. Behavior varies; many routers ship with UPnP disabled by default.</li>
|
||||
<li><strong>NAT-PMP</strong> (RFC 6886) — Apple lineage; widespread on routers that ever shipped Bonjour. Unicast to the gateway on UDP/5351.</li>
|
||||
<li><strong>PCP</strong> (RFC 6887) — modern IETF-track successor to NAT-PMP. Unicast on UDP/5351. Supports both IPv4 NAT mapping and IPv6 firewall pinholes. Works on iOS without the multicast networking entitlement.</li>
|
||||
</ul>
|
||||
<p>Implementation uses the <code>portmapper</code> crate (also used by iroh internally). Replaces the v0.7.1 hand-rolled <code>igd-next</code>-only path.</p>
|
||||
<p>UPnP (Universal Plug and Play) allows a node to request its home router to forward an external port to its local QUIC port. This makes the node <strong>directly reachable from the internet</strong> without hole punching — any peer with the external address can connect immediately. This dramatically improves connection success rates for desktop nodes on home networks.</p>
|
||||
|
||||
<h3>Startup flow</h3>
|
||||
<pre><code>bind Endpoint → spawn portmapper Client (UDP) → wait up to 3s for first protocol response → bootstrap (TCP mapping fires in parallel for HTTP serving)</code></pre>
|
||||
<pre><code>bind Endpoint → attempt UPnP mapping (2s timeout) → store external addr → bootstrap</code></pre>
|
||||
<ol style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
|
||||
<li><strong>Probe all three protocols in parallel</strong>: portmapper's background service fires UPnP-IGD discovery + NAT-PMP unicast + PCP unicast concurrently. First success wins; failures from the others are absorbed silently.</li>
|
||||
<li><strong>UDP mapping for QUIC</strong>: maps the local QUIC port to an external port. Required for direct inbound. Address feeds N+10 identification, InitialExchange, anchor registration, and peer address advertisements.</li>
|
||||
<li><strong>TCP mapping for HTTP</strong>: separate parallel attempt for HTTP serving (see <a href="#http-delivery">Section 25</a>). Independent of UDP — either can succeed alone. Phones with permissive NAT can serve HTTP directly to browser fetches as of v0.7.2.</li>
|
||||
<li><strong>Per-platform behavior</strong>: All three protocols on desktop. On Android, a WiFi/Ethernet gate skips probing on cellular (no UPnP/PCP gateway exposed by carriers) and a <code>WifiManager.MulticastLock</code> is held for the lifetime of the mapping so UPnP-IGD's SSDP responses actually arrive. On iOS, PCP and NAT-PMP work without the multicast entitlement; UPnP-IGD silently fails until the entitlement is granted.</li>
|
||||
<li><strong>Discover gateway</strong>: Search for UPnP/NAT-PMP gateway with a 2-second timeout. If no gateway found, proceed without — do not block startup.</li>
|
||||
<li><strong>Request mapping</strong>: Map both UDP and TCP for the local QUIC port to the same external port (or next available). UDP is required for QUIC (existing). TCP enables HTTP post delivery (see <a href="#http-delivery">Section 25</a>). Both use the same external port number. If the router supports one but not the other, accept the partial mapping gracefully — QUIC connectivity is not affected by TCP mapping failure. Request lease TTL of 3600s.</li>
|
||||
<li><strong>Store external address</strong>: The resulting external <code>SocketAddr</code> is stored alongside iroh's observed addresses. It feeds into N+10 identification, InitialExchange, anchor registration, and all peer address advertisements.</li>
|
||||
<li><strong>Log result</strong>: Clearly log whether UPnP succeeded, failed, or was unavailable. This is critical for diagnosing connectivity issues.</li>
|
||||
</ol>
|
||||
|
||||
<h3>Auto-renewal</h3>
|
||||
<p>The <code>portmapper::Client</code> renews leases internally in a background task. No external renewal cycle to schedule. Dropping the <code>PortMapping</code> handle aborts the renewal task and releases the mapping.</p>
|
||||
|
||||
<h3>Bidirectional anchor reachability watcher</h3>
|
||||
<p>A startup-spawned task watches the UDP mapping's reactive external-address channel:</p>
|
||||
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
|
||||
<li><strong>Mapping lost for >5 min</strong> → clear <code>is_anchor</code>. The node stops advertising itself as an anchor at a now-stale external address.</li>
|
||||
<li><strong>Mapping restored</strong> (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.</li>
|
||||
</ul>
|
||||
<p>Network roams between UPnP-capable WiFi networks self-heal. Mobile devices never auto-anchor regardless — cellular IPs look public but sit behind CGNAT.</p>
|
||||
<h3>Lease renewal cycle (every 2700s / 45 min)</h3>
|
||||
<p>UPnP mappings have a TTL (typically 3600s but varies by router). A renewal loop runs every 45 minutes to refresh the mapping before it expires. If renewal fails, the external address is removed from advertisements and the node falls back to hole punch / relay paths gracefully.</p>
|
||||
|
||||
<h3>Shutdown</h3>
|
||||
<p>Explicitly release the mapping on clean shutdown. Routers have finite mapping tables — releasing is good citizenship.</p>
|
||||
<p>Explicitly release the UPnP mapping on clean shutdown. Routers have finite mapping tables — releasing is good citizenship. Tauri's shutdown hook handles this.</p>
|
||||
|
||||
<h3>Integration with existing address logic</h3>
|
||||
<p>The UPnP external address is treated the same as any other address the node knows about. It feeds into:</p>
|
||||
|
|
@ -1329,7 +1305,7 @@ END</code></pre>
|
|||
<tr><td><code>Public</code></td><td>None</td><td>Unlimited</td></tr>
|
||||
<tr><td><code>Encrypted { recipients }</code></td><td>~60 bytes per recipient</td><td>~500 (256KB cap)</td></tr>
|
||||
<tr><td><code>GroupEncrypted { group_id, epoch, wrapped_cek }</code></td><td>~100 bytes total</td><td>Unlimited (one CEK wrap for the group)</td></tr>
|
||||
<tr><td><code>FoFClosed</code> <span class="badge badge-complete">v0.7.0</span></td><td>~154 bytes per admitted V_x, padded</td><td>Bucketed (8/16/32/64/128/256, then +128 steps)</td></tr>
|
||||
<tr><td><code>FoFClosed { pub_post_set, wrap_slots }</code> <span class="badge badge-planned">Planned</span></td><td>~154 bytes per admitted V_x, padded</td><td>Bucketed (8/16/32/64/128/256, then +128 steps)</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>PostId integrity</h3>
|
||||
|
|
@ -1378,7 +1354,7 @@ END</code></pre>
|
|||
|
||||
<!-- 20a. Friend-of-Friend Visibility -->
|
||||
<section id="fof">
|
||||
<h2>20a. Friend-of-Friend Visibility <span class="badge badge-complete">v0.7.0</span></h2>
|
||||
<h2>20a. Friend-of-Friend Visibility <span class="badge badge-planned">Planned</span></h2>
|
||||
|
||||
<div class="note">
|
||||
<strong>Distinct from directory vouches.</strong> The "FoF vouch" described here is a <em>cryptographic</em> primitive for post readership and comment gating (per-persona symmetric key <code>V_me</code>). It is unrelated to the <em>directory vouch</em> system in <a href="#directory">section 27</a>, which governs discovery-layer trust and bot-ring resistance. The two share vocabulary but operate at different layers.
|
||||
|
|
@ -1467,11 +1443,11 @@ END</code></pre>
|
|||
<p>Full crypto-level byte layouts, data models, wire-format additions, ship criteria, and integration tests are specified in <code>docs/fof-spec/</code>. The implementation is layered for bottom-up shipping:</p>
|
||||
<table>
|
||||
<tr><th>Layer</th><th>Scope</th><th>Status</th></tr>
|
||||
<tr><td>1</td><td>Vouch primitive (V_x keys, keyring, bio-post HPKE wrappers, scan policy)</td><td><span class="badge badge-complete">v0.7.0</span></td></tr>
|
||||
<tr><td>2</td><td>Mode 2: public posts with FoF-gated comments, CDN-level verification</td><td><span class="badge badge-complete">v0.7.0</span></td></tr>
|
||||
<tr><td>3</td><td>Mode 1: <code>FoFClosed</code> body + wrap slots + anonymous prefilter</td><td><span class="badge badge-complete">v0.7.0</span></td></tr>
|
||||
<tr><td>4</td><td>Rotation, revocation, key lifecycle (grandfather + cascade + key-burn)</td><td><span class="badge badge-complete">v0.7.0</span></td></tr>
|
||||
<tr><td>5</td><td>Unlock cache + prefilter optimization (perf-critical at scale)</td><td><span class="badge badge-complete">v0.7.0</span></td></tr>
|
||||
<tr><td>1</td><td>Vouch primitive (V_x keys, keyring, bio-post HPKE wrappers, scan policy)</td><td><span class="badge badge-planned">Planned</span></td></tr>
|
||||
<tr><td>2</td><td>Mode 2: public posts with FoF-gated comments, CDN-level verification</td><td><span class="badge badge-planned">Planned</span></td></tr>
|
||||
<tr><td>3</td><td>Mode 1: <code>FoFClosed</code> body + wrap slots + anonymous prefilter</td><td><span class="badge badge-planned">Planned</span></td></tr>
|
||||
<tr><td>4</td><td>Rotation, revocation, key lifecycle (grandfather + cascade + key-burn)</td><td><span class="badge badge-planned">Planned</span></td></tr>
|
||||
<tr><td>5</td><td>Unlock cache + prefilter optimization (perf-critical at scale)</td><td><span class="badge badge-planned">Planned</span></td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -26,102 +26,26 @@
|
|||
<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>
|
||||
|
||||
<h2 style="margin-top: 2rem;">v0.7.3 — May 15, 2026</h2>
|
||||
<p style="color: var(--text-muted); font-size: 0.85rem;">Bandwidth + bootstrap hardening on top of v0.7.2. Wire-compatible with v0.7.0/v0.7.1/v0.7.2.</p>
|
||||
|
||||
<div class="downloads">
|
||||
<a href="itsgoin-0.7.3.apk" class="download-btn btn-android">
|
||||
Android APK
|
||||
<span class="sub">v0.7.3</span>
|
||||
</a>
|
||||
<a href="itsgoin_0.7.3_amd64.AppImage" class="download-btn btn-linux">
|
||||
Linux AppImage
|
||||
<span class="sub">v0.7.3</span>
|
||||
</a>
|
||||
<a href="itsgoin-cli-0.7.3-linux-amd64" class="download-btn btn-linux">
|
||||
Linux CLI / Anchor
|
||||
<span class="sub">v0.7.3</span>
|
||||
</a>
|
||||
<a href="itsgoin-0.7.3-windows-x64-setup.exe" class="download-btn btn-windows">
|
||||
Windows Installer
|
||||
<span class="sub">v0.7.3</span>
|
||||
</a>
|
||||
<div class="note" style="margin-top: 1rem; border-left: 3px solid var(--accent); padding-left: 0.9rem;">
|
||||
<strong>Upgrading from v0.5.1 or newer?</strong> v0.6 is a hard network fork — older versions can no longer reach the network. Bring your account over in three steps:
|
||||
<ol style="margin: 0.5rem 0 0.25rem 1.25rem; padding: 0;">
|
||||
<li>On your current v0.5 install, go to <strong>Settings → Export</strong>, tick <em>“Posts + media + identity key”</em>, and save the .zip somewhere safe.</li>
|
||||
<li>Download v0.6 below (APK, AppImage, or Windows installer).</li>
|
||||
<li>On first launch, choose <strong>“Import an Identity”</strong> and point it at the .zip.</li>
|
||||
</ol>
|
||||
<p style="margin: 0.5rem 0 0 0; font-size: 0.8rem; color: var(--text-muted);">Your posts, media, follows, and identity key all come across. Encrypted posts stay decryptable under the same key.</p>
|
||||
</div>
|
||||
|
||||
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 1rem;">
|
||||
<li><strong>EDM port scanner disabled.</strong> The "advanced NAT traversal" port-scanner (Hard NAT ↔ Hard NAT) used <code>endpoint.connect()</code> as its probe primitive; iroh accumulates every connect target into its per-endpoint path store and probes them all in the background under QUIC NAT-traversal. A 5-min scan inserted ~30k paths; iroh then probed all of them — observed at 22MB/s outbound from a single client. DoS-grade at any scale. Disabled until we replace per-probe <code>connect()</code> with raw UDP sends. The scanner source is preserved as <code>edm_port_scan_disabled_v0_7_3</code> to refactor against.</li>
|
||||
<li><strong>Bootstrap anchor probing batched.</strong> Discovered anchors are now probed 3 at a time with a 2s stagger between batches and a 10s per-anchor timeout. First success unblocks the bootstrap flow immediately; remaining probes continue in background and naturally fill peer connections. Phase 2 (bootstrap fallback) still only fires when every discovered anchor has failed — preserves the load-distribution intent for when the network scales.</li>
|
||||
<li><strong>Stale-anchor self-pruning.</strong> When a probe fails AND the anchor's <code>last_seen_ms</code> is more than 3 days old, the entry is deleted from <code>known_anchors</code> immediately. Recoverable anchors (failed once, succeeded recently) are preserved. Users with old data dirs whose discovered anchors point to keypairs that rotated months ago no longer carry stale baggage forward.</li>
|
||||
<li><strong>Close button kills the Android NodeService.</strong> The in-app close button now calls <code>NodeService.stopFromNative()</code> via JNI before exiting the Activity, so the foreground service actually stops — previously the button ended the UI but networking kept running.</li>
|
||||
<li><strong>Power-icon SVG.</strong> The close-button glyph is now an inline SVG instead of <code>&#x23FB;</code> — Android webview fonts that lack U+23FB previously rendered the button as a missing-image tofu box.</li>
|
||||
</ul>
|
||||
|
||||
<h2 style="margin-top: 2rem;">v0.7.2 — May 15, 2026</h2>
|
||||
<p style="color: var(--text-muted); font-size: 0.85rem;">Network & reachability improvements, plus a relay-privacy fix.</p>
|
||||
|
||||
<div class="downloads">
|
||||
<a href="itsgoin-0.7.2.apk" class="download-btn btn-android">
|
||||
Android APK
|
||||
<span class="sub">v0.7.2</span>
|
||||
</a>
|
||||
<a href="itsgoin_0.7.2_amd64.AppImage" class="download-btn btn-linux">
|
||||
Linux AppImage
|
||||
<span class="sub">v0.7.2</span>
|
||||
</a>
|
||||
<a href="itsgoin-cli-0.7.2-linux-amd64" class="download-btn btn-linux">
|
||||
Linux CLI / Anchor
|
||||
<span class="sub">v0.7.2</span>
|
||||
</a>
|
||||
<a href="itsgoin-0.7.2-windows-x64-setup.exe" class="download-btn btn-windows">
|
||||
Windows Installer
|
||||
<span class="sub">v0.7.2</span>
|
||||
</a>
|
||||
<div class="note" style="margin-top: 0.75rem; border-left: 3px solid var(--text-muted); padding-left: 0.9rem;">
|
||||
<strong>Upgrading from v0.5.0 or older?</strong> The v0.5 export format matured in v0.5.1, so a direct export from 0.5.0 won't import cleanly into v0.6. Do a two-hop upgrade:
|
||||
<ol style="margin: 0.5rem 0 0.25rem 1.25rem; padding: 0;">
|
||||
<li>Install <a href="itsgoin_0.5.3_amd64.AppImage">v0.5.3 Linux AppImage</a> / <a href="itsgoin-0.5.3.apk">Android APK</a> / <a href="itsgoin-0.5.3-windows-x64-setup.exe">Windows installer</a> and open it. It reads your existing data in place.</li>
|
||||
<li>In v0.5.3, go to <strong>Settings → Export</strong> and save the bundle.</li>
|
||||
<li>Install v0.6.0 below and import that bundle on first launch.</li>
|
||||
</ol>
|
||||
<p style="margin: 0.5rem 0 0 0; font-size: 0.8rem; color: var(--text-muted);">v0.5.3 is kept online only as an upgrade bridge — it no longer connects to the live network.</p>
|
||||
</div>
|
||||
|
||||
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 1rem;">
|
||||
<li><strong>NAT traversal: UPnP + NAT-PMP + PCP.</strong> Replaced UPnP-only port mapping with the <code>portmapper</code> crate (also used by iroh internally). All three protocols run in parallel; the first router-response wins. PCP adds IPv6 firewall pinholes and works on iOS without a multicast entitlement. Auto-renewal runs in a background task — no more lease-expiry surprises.</li>
|
||||
<li><strong>Android NAT mapping on WiFi.</strong> Mobile devices on WiFi now attempt UPnP/PCP/NAT-PMP just like desktops, with the required <code>WifiManager.MulticastLock</code> acquired for the lifetime of the mapping. Cellular is gated off (no UPnP/PCP gateway on carrier nets, so we skip the discovery cost). Reachability for phones-on-home-WiFi should improve noticeably.</li>
|
||||
<li><strong>Mobile HTTP serving when reachable.</strong> Phones whose router cooperates with TCP port mapping can now serve <code>/p/<post></code> directly for browser fetches. No mode switch — this just works when the network allows it.</li>
|
||||
<li><strong>Anchor-mode self-heals across network changes.</strong> A new watcher observes the live port mapping. Mapping lost >5min → anchor mode clears (don't keep advertising an unreachable address). Mapping restored → anchor mode comes back on, no restart needed. Roaming between UPnP-capable WiFi networks no longer requires a reboot.</li>
|
||||
<li><strong>Shorter share URLs.</strong> Share links now contain only the post ID — <code>itsgoin.net/p/<post></code>. The anchor handles holder lookup itself, so URLs stay stable as the holder set changes. Older URLs with the author hex appended continue to work.</li>
|
||||
<li><strong>Session relay is opt-in, off by default.</strong> Fixed a regression where hole-punch failures could silently pipe a peer-to-peer session through an intermediary's bandwidth. The previous behavior burned random users' bandwidth without consent. A new Settings toggle (<em>Enable session relay</em>) opts in to both serving as a relay and using one. Anchors default-off too — server bandwidth shouldn't be silently consumed either. Hole-punch coordination (small signaling) is unaffected.</li>
|
||||
<li><strong>Quick app close from the header.</strong> Power icon next to the network indicator fully terminates the app (with confirm). Useful on mobile to stop network activity between active sessions.</li>
|
||||
</ul>
|
||||
<p style="color: var(--text-muted); font-size: 0.85rem;">v0.7.2 is wire-compatible with v0.7.0 and v0.7.1. No protocol changes; all network & UX improvements are local.</p>
|
||||
|
||||
<h2 style="margin-top: 2rem;">v0.7.1 — May 15, 2026</h2>
|
||||
<p style="color: var(--text-muted); font-size: 0.85rem;">UI polish + bug-fix pass on top of v0.7.0's FoF gating. Default post visibility is now Extended Friends (FoF). New <em>Friend</em> button combines follow + vouch in one click. Network Identity renamed to Device Address (you almost never need to touch it). Settings clearly separates personas from device address with an export/import "Move to another device" flow. Plus three fixes: profile display name now updates everywhere when changed; redundancy panel reads from the correct author set so it no longer shows 0 for all posts; My Posts tab no longer horizontally overflows and breaks the sticky header/tabs.</p>
|
||||
|
||||
<div class="downloads">
|
||||
<a href="itsgoin-0.7.1.apk" class="download-btn btn-android">
|
||||
Android APK
|
||||
<span class="sub">v0.7.1</span>
|
||||
</a>
|
||||
<a href="itsgoin_0.7.1_amd64.AppImage" class="download-btn btn-linux">
|
||||
Linux AppImage
|
||||
<span class="sub">v0.7.1</span>
|
||||
</a>
|
||||
<a href="itsgoin-cli-0.7.1-linux-amd64" class="download-btn btn-linux">
|
||||
Linux CLI / Anchor
|
||||
<span class="sub">v0.7.1</span>
|
||||
</a>
|
||||
<a href="itsgoin-0.7.1-windows-x64-setup.exe" class="download-btn btn-windows">
|
||||
Windows Installer
|
||||
<span class="sub">v0.7.1</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 1rem;">
|
||||
<li><strong>Default visibility is Extended Friends (FoF).</strong> Public is one click away when you want it; encrypted-by-default is the new posture.</li>
|
||||
<li><strong>Friend button</strong> on the bio modal: follow + vouch in one tap. "Follow only" stays available for the watch-but-don't-trust case. "Unfriend" reverses both.</li>
|
||||
<li><strong>Network Identity → Device Address.</strong> UI rename for clarity; the device's QUIC endpoint is distinct from your posting personas. Backend command names are unchanged.</li>
|
||||
<li><strong>Settings — Move to another device.</strong> Export-personas / import-from-another-device buttons get their own labeled section with a plain-English explainer.</li>
|
||||
<li><strong>Bug fix: profile display name update.</strong> Renaming the auto-generated first persona now propagates everywhere (the posting-identities table was previously stuck on the original empty name).</li>
|
||||
<li><strong>Bug fix: redundancy panel.</strong> Was querying posts authored by the device's network NodeId; since v0.6.0 posts are authored by personas, so the query returned 0 of your posts. Now queries every persona on the device.</li>
|
||||
<li><strong>Bug fix: My Posts horizontal-scroll regression.</strong> The new "FoF only (Mode 1)" compose-policy option pushed the visibility row past viewport width on mobile, triggering browser-chrome auto-hide behavior. Row now wraps; selects cap at container width.</li>
|
||||
</ul>
|
||||
<p style="color: var(--text-muted); font-size: 0.85rem;">v0.7.1 is wire-compatible with v0.7.0. UI/UX only.</p>
|
||||
|
||||
<h2 style="margin-top: 2rem;">v0.7.0 — May 15, 2026</h2>
|
||||
<p style="color: var(--text-muted); font-size: 0.85rem;">Friend-of-Friend gating is live. Posts can be public to readers but FoF-gated for comments (Mode 2), or fully FoF-gated for body + comments (Mode 1, <code>FoFClosed</code>). The CDN verifies comment signatures before propagating, killing the bandwidth-DoS attack a single admitted FoF member could otherwise mount. Vouches distribute via HPKE-sealed wrappers in your bio post — no DMs, no recipient IDs on the wire.</p>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue