From ad9282f24a76a2f8036a109a02184139b116e791 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 14 May 2026 20:57:25 -0600 Subject: [PATCH 1/9] docs: flip FoF section 20a badges to v0.7.0 + sessions.md release entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit design.html section 20a (Friend-of-Friend Visibility): - Section header badge: planned → v0.7.0 complete - FoFClosed PostVisibility table row: planned → v0.7.0 - All 5 layer rows in the implementation table: planned → v0.7.0 - Custom-subset row retained as v2 (genuinely deferred per spec) sessions.md: full session entry for the Layer 1-5 implementation arc. 34 commits, ~24 new fof:: integration tests, key design decisions preserved (slot_binder_nonce circularity fix, per-post pub_x/priv_x, multi-epoch receiver-chain V_me storage, retroactive cascade delete, key-burn semantics). Co-Authored-By: Claude Opus 4.7 (1M context) --- sessions.md | 68 +++++++++++++++++++++++++++++++++++++++++++++ website/design.html | 14 +++++----- 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/sessions.md b/sessions.md index 52fe532..2450b85 100644 --- a/sessions.md +++ b/sessions.md @@ -6,6 +6,74 @@ 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 diff --git a/website/design.html b/website/design.html index 032cda5..9a7050b 100644 --- a/website/design.html +++ b/website/design.html @@ -1305,7 +1305,7 @@ END PublicNoneUnlimited Encrypted { recipients }~60 bytes per recipient~500 (256KB cap) GroupEncrypted { group_id, epoch, wrapped_cek }~100 bytes totalUnlimited (one CEK wrap for the group) - FoFClosed { pub_post_set, wrap_slots } Planned~154 bytes per admitted V_x, paddedBucketed (8/16/32/64/128/256, then +128 steps) + FoFClosed v0.7.0~154 bytes per admitted V_x, paddedBucketed (8/16/32/64/128/256, then +128 steps)

PostId integrity

@@ -1354,7 +1354,7 @@ END
-

20a. Friend-of-Friend Visibility Planned

+

20a. Friend-of-Friend Visibility v0.7.0

Distinct from directory vouches. The "FoF vouch" described here is a cryptographic primitive for post readership and comment gating (per-persona symmetric key V_me). It is unrelated to the directory vouch system in section 27, which governs discovery-layer trust and bot-ring resistance. The two share vocabulary but operate at different layers. @@ -1443,11 +1443,11 @@ END

Full crypto-level byte layouts, data models, wire-format additions, ship criteria, and integration tests are specified in docs/fof-spec/. The implementation is layered for bottom-up shipping:

- - - - - + + + + +
LayerScopeStatus
1Vouch primitive (V_x keys, keyring, bio-post HPKE wrappers, scan policy)Planned
2Mode 2: public posts with FoF-gated comments, CDN-level verificationPlanned
3Mode 1: FoFClosed body + wrap slots + anonymous prefilterPlanned
4Rotation, revocation, key lifecycle (grandfather + cascade + key-burn)Planned
5Unlock cache + prefilter optimization (perf-critical at scale)Planned
1Vouch primitive (V_x keys, keyring, bio-post HPKE wrappers, scan policy)v0.7.0
2Mode 2: public posts with FoF-gated comments, CDN-level verificationv0.7.0
3Mode 1: FoFClosed body + wrap slots + anonymous prefilterv0.7.0
4Rotation, revocation, key lifecycle (grandfather + cascade + key-burn)v0.7.0
5Unlock cache + prefilter optimization (perf-critical at scale)v0.7.0
From ec393c7f85fda257a5123169dc5c18058fdda244 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 14 May 2026 21:12:51 -0600 Subject: [PATCH 2/9] fix(deploy): serialize CLI/APK/AppImage builds to avoid cargo target contention The v0.7.0 release surfaced a real bug: parallel cargo invocations all writing to target/ caused linuxdeploy to fail mid-AppImage bundle ("Error failed to bundle project failed to run linuxdeploy"). Cargo's own target-dir locking doesn't fully serialize three concurrent cargo-front-ends across CLI + Android-cross-compile + tauri-bundle. Solo cargo tauri build succeeded immediately after deploy.sh aborted, confirming the parallel invocation was the root cause. Switching to serial builds. The extra wall time is small because cargo's incremental cache deduplicates compilation of the shared itsgoin-core crate across the three builds. Co-Authored-By: Claude Opus 4.7 (1M context) --- deploy.sh | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/deploy.sh b/deploy.sh index c780395..f89cdca 100755 --- a/deploy.sh +++ b/deploy.sh @@ -21,23 +21,20 @@ KS_ALIAS="itsgoin" VERSION=$(grep '"version"' crates/tauri-app/tauri.conf.json | head -1 | sed 's/.*"\([0-9.]*\)".*/\1/') echo "=== Deploying v${VERSION} ===" -# Build CLI -echo "=== Building CLI ===" -cargo build -p itsgoin-cli --release & -CLI_PID=$! +# 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. -# Build APK -echo "=== Building APK ===" -cargo tauri android build --apk & -APK_PID=$! - -# Build AppImage (includes GStreamer patch) -echo "=== Building AppImage ===" +echo "=== Building AppImage (includes GStreamer patch) ===" ./build-appimage.sh -wait $CLI_PID -echo "=== CLI build complete ===" -wait $APK_PID -echo "=== APK build complete ===" + +echo "=== Building APK ===" +cargo tauri android build --apk + +echo "=== Building CLI ===" +cargo build -p itsgoin-cli --release # Sign APK echo "=== Signing APK ===" From f714a1738535d5152075e019c60fe7a1d065f460 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 14 May 2026 21:42:54 -0600 Subject: [PATCH 3/9] fix(ui): wrap #visibility-row and cap select width to prevent My Posts horizontal overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom (mobile): on the My Posts tab the fixed header + bottom tabs disappeared until a swipe gesture pulled them in — classic mobile- browser-chrome auto-hide that fires when the body has unwanted horizontal scroll. Root cause: the new FoF compose option "Body+Comments: FoF only (Mode 1)" pushed the unwrapped #visibility-row (5 dropdowns side by side, no flex-wrap) past viewport width on phones. Body picked up overflow-x → mobile browser detected horizontal scroll → fixed positioning behavior gets weird in that mode on Chrome Android and WebView. Two-line fix: - flex-wrap: wrap on #visibility-row so the row breaks to multiple lines on narrow screens. - max-width: 100% on the row and on the selects inside it; the SELECT widget renders at most container-width even if its longest option is wider, so the long FoF option no longer balloons the widget. Verified by inspection: no other view has a similar wide-dropdown row that hugs the right edge; this is My Posts-specific because compose lives there. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/style.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/style.css b/frontend/style.css index d282bb2..4ecbcfd 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -224,7 +224,8 @@ 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; gap: 0.4rem; margin-top: 0.35rem; align-items: center; } +#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-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; } From 346d23d4d8fc9cc3270a63cf66cf46a9e7240297 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 14 May 2026 22:32:30 -0600 Subject: [PATCH 4/9] ux: Friend-button default + profile-rename plumbing + export/import clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the v0.7.0 device-testing feedback items, deferred for later rebuild/redeploy (Scott opted to batch UI fixes). (#1) Friend button on bio modal: - Primary action when neither following nor vouched: [Friend] (= follow + vouch in one click) plus secondary [Follow only]. - When following without a vouch: [Add Vouch] primary, [Unfollow] secondary. - When both follow + vouch (= friends): [Unfriend] (= revoke vouch + unfollow, with the rotation-cost confirm wording). - The standalone [Vouch] / [Revoke Vouch] flows stay reachable from the existing Vouches list in Settings. (#2) Profile shows "unnamed" — bug fix: - set_profile updated the profiles table + emitted a profile post, but never updated posting_identities.display_name. list_posting_ identities returns from the latter, so the Personas list kept showing "(unnamed)" forever after the first-run auto-persona was named. - Now set_profile also upserts the posting_identities row with the new display_name (secret_seed + created_at preserved). (#5) Export/import + persona-vs-device clarity: - Settings reorder: new "Your data on this device" explainer card up top (Personas = who you are to peers; Identities = device's network address, rarely useful to touch). - "Move to another device" section renamed + given a plain-English description; primary [Export personas] / [Import from another device] buttons. - "Identities (advanced)" demoted below; warning text added. - Export wizard heading: "Export your personas"; radio labels use persona/keys language consistently. - Import wizard heading: "Import from another device"; explainer notes that the default action restores personas. Tracking memory created at memory/project_v071_followups.md for deferred items (#4 PQ vouch delivery, #6 rename, #7 redundancy, #8/#9/#10 awaiting clarification). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/node.rs | 14 ++++++++++ frontend/app.js | 61 +++++++++++++++++++++++++++-------------- frontend/index.html | 34 +++++++++++++++++------ 3 files changed, 79 insertions(+), 30 deletions(-) diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 7c90407..8dd0d46 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -1793,6 +1793,20 @@ 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. diff --git a/frontend/app.js b/frontend/app.js index 1840b8e..5bf2922 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1687,12 +1687,13 @@ async function openBioModal(nodeId, preloadedName) { ${bio ? `

${escapeHtml(bio)}

` : '

No bio.

'}
- ${following - ? `` - : ``} - ${isVouched - ? `` - : ``} + ${(following && isVouched) + ? `` + : (following + ? ` + ` + : ` + `)} ${isIgnored ? `` @@ -1749,16 +1750,34 @@ async function openBioModal(nodeId, preloadedName) { } catch (e) { toast('Error: ' + e); } finally { vouch.disabled = false; } }; - 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; + // 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; try { await invoke('revoke_vouch_for_peer', { nodeIdHex: nodeId }); - toast('Revoked and rotated'); + await invoke('unfollow_node', { nodeIdHex: nodeId }); + toast(`Unfriended ${name}`); close(); + loadFollows(); + loadFeed(true); } catch (e) { toast('Error: ' + e); } - finally { revokeVouch.disabled = false; } + finally { unfriend.disabled = false; } }; } catch (e) { bodyEl.innerHTML = `

Error: ${e}

`; @@ -4041,14 +4060,14 @@ $('#export-btn').addEventListener('click', () => { overlay.className = 'image-lightbox'; overlay.style.cursor = 'default'; overlay.innerHTML = ` -
-

Export Data

-

Choose what to include in the export ZIP.

+
+

Export your personas

+

Save your personas + (optionally) your posts to a ZIP file so you can import them on another device.

- - - - + + + +
@@ -4118,8 +4137,8 @@ $('#import-btn').addEventListener('click', () => { overlay.style.cursor = 'default'; overlay.innerHTML = `
-

Import Data

-

Select an ItsGoin export ZIP file.

+

Import from another device

+

Select an ItsGoin export ZIP. Default action restores the exported personas onto this device so you can post as them.

diff --git a/frontend/index.html b/frontend/index.html index e415fd8..ba80394 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -231,13 +231,13 @@
-
-

Identities

-
-
- - -
+
+

Your data on this device

+

+ Personas 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 export your personas from this device and import them on the new one. +

+ Identities below are this device's own network address — usually not what you want to move. Leave them alone unless you know why you're touching them. +

@@ -248,9 +248,25 @@
+

Move to another device

+

+ Export creates a ZIP containing your personas (and optionally posts/follows). Import on the other device's Settings > Move to another device. +

- - + + +
+
+ +
+

Identities (advanced)

+

+ This device's network address. Changing this is rarely useful — it lets you move the device's QUIC endpoint, NOT your posting identity. +

+
+
+ +
From 83fd30753f479a75474d8dd740b087e530b7a1e3 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 14 May 2026 22:39:44 -0600 Subject: [PATCH 5/9] ux: default visibility = Extended Friends (FoF) + clean visibility picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #8 from the v0.7.0 device-test feedback round, confirmed by Scott. Promotes FoF from a comment-policy option to a top-level visibility choice, with FoF as the new default. - visibility-select: new option order with `fof_closed` selected by default: - Extended Friends (FoF) ← default - Friends - Public - Circle - comment-perm-select: removed the old `fof_closed` option (its job is now done by the visibility picker). Kept `friends_of_friends` for the Mode 2 combo (public body + FoF-gated comments). - updateVisibilityUI now hides comment-perm-select when visibility is `fof_closed` — the audience choice already implies the comment policy, no extra picker needed. Shown again on public/friends/circle. - Compose dispatch logic re-rooted on visibility instead of comment policy: - fof_closed → create_post_fof_closed (Mode 1) - public + comment-perm=friends_of_friends → create_post_with_fof_comments (Mode 2) - everything else → existing create_post path - After-post reset goes to `fof_closed` (not `public`) to preserve the privacy-by-default posture. Tracker memory updated: #8 marked complete; #10 clarified (per-group persona selection, scoped alongside the deferred Group UI work in #9). Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/app.js | 48 +++++++++++++++++++++++---------------------- frontend/index.html | 4 ++-- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index 5bf2922..705cb18 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -2662,7 +2662,7 @@ async function doPost() { try { const vis = visibilitySelect.value; const params = { content: content || '' }; - if (vis !== 'public') { + if (vis !== 'public' && vis !== 'fof_closed') { params.visibility = vis; } if (vis === 'circle') { @@ -2686,28 +2686,11 @@ async function doPost() { const reactPerm = document.getElementById('react-perm-select').value; let result; - 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 (vis === 'fof_closed') { + // Visibility = Extended Friends (FoF). Body + comments are + // encrypted under the FoF gating CEK. Mode 1. if (selectedFiles.length > 0 || params.postingIdHex) { - toast('FoF posts with attachments or non-default persona not yet supported.'); - postBtn.disabled = false; - return; - } - const created = await invoke('create_post_with_fof_comments', { - 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.'); + toast('FoF (Extended Friends) posts with attachments or non-default persona not yet supported.'); postBtn.disabled = false; return; } @@ -2715,6 +2698,17 @@ async function doPost() { 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.'); + postBtn.disabled = false; + return; + } + const created = await invoke('create_post_with_fof_comments', { + content: params.content, + }); + result = { id: created.postId }; } else if (selectedFiles.length > 0) { // Convert ArrayBuffers to base64 strings const files = selectedFiles.map(f => { @@ -2747,7 +2741,7 @@ async function doPost() { selectedFiles = []; renderAttachmentPreview(); updateCharCount(); - visibilitySelect.value = 'public'; + visibilitySelect.value = 'fof_closed'; updateVisibilityUI(); toast('Posted!'); loadFeed(true); @@ -3001,6 +2995,11 @@ 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() { @@ -3018,6 +3017,9 @@ 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() { diff --git a/frontend/index.html b/frontend/index.html index ba80394..4c02f53 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -96,8 +96,9 @@
@@ -105,7 +106,6 @@ - +

New Device Address

+

A new QUIC network endpoint for this device. Your personas are unaffected.

+
@@ -3654,10 +3657,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('Name is required'); return; } + if (!name) { toast('Label is required'); return; } try { const nodeId = await invoke('create_identity', { name }); - toast(`Identity created: ${nodeId.substring(0, 12)}`); + toast(`Device address created: ${nodeId.substring(0, 12)}`); overlay.remove(); loadIdentities(); } catch (e) { toast('Error: ' + e); } @@ -3672,10 +3675,10 @@ $('#import-identity-btn').addEventListener('click', () => { overlay.style.cursor = 'default'; overlay.innerHTML = `
-

Import Identity

-

Paste the 64-character hex key from an identity export.

- - +

Import Device Address Key

+

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.

+ +
@@ -3688,7 +3691,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(`Identity imported: ${nodeId.substring(0, 12)}`); + toast(`Device address imported: ${nodeId.substring(0, 12)}`); overlay.remove(); loadIdentities(); } catch (e) { toast('Error: ' + e); } @@ -4406,7 +4409,7 @@ async function init() {

How would you like to get started?

- +
`; document.body.appendChild(chooser); diff --git a/frontend/index.html b/frontend/index.html index 4c02f53..7abd8f2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -236,7 +236,7 @@

Personas 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 export your personas from this device and import them on the new one.

- Identities below are this device's own network address — usually not what you want to move. Leave them alone unless you know why you're touching them. + Device Address 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.

@@ -259,14 +259,14 @@
-

Identities (advanced)

+

Device Address (advanced)

- This device's network address. Changing this is rarely useful — it lets you move the device's QUIC endpoint, NOT your posting identity. + 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.

- - + +
@@ -316,7 +316,7 @@

Danger Zone

-

Delete all local data. Identity key preserved.

+

Delete all local data. Device address key preserved.

From 069257c2d80edd081af001a22bc7013a622d4fd9 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 14 May 2026 22:56:52 -0600 Subject: [PATCH 7/9] chore: bump version to 0.7.1 + v0.7.1 release notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps Cargo.toml + tauri.conf.json + android tauri.properties (versionCode 7000 → 7001). v0.7.1 = UI polish + bug-fix pass on v0.7.0, wire-compatible. download.html: new v0.7.1 section at top with detailed bullets for: - Friend button (follow+vouch) - Default visibility = Extended Friends (FoF) - Network Identity → Device Address rename - Settings persona/device clarity - Profile-display-name bug fix - Redundancy panel author-query fix - My Posts horizontal-scroll regression fix v0.7.0 entry retained below with full original release notes. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 6 +++--- crates/cli/Cargo.toml | 2 +- crates/core/Cargo.toml | 2 +- crates/tauri-app/Cargo.toml | 2 +- crates/tauri-app/tauri.conf.json | 2 +- website/download.html | 33 ++++++++++++++++++++++++++++++++ 6 files changed, 40 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5689dce..8b5b9a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2732,7 +2732,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "itsgoin-cli" -version = "0.7.0" +version = "0.7.1" dependencies = [ "anyhow", "hex", @@ -2744,7 +2744,7 @@ dependencies = [ [[package]] name = "itsgoin-core" -version = "0.7.0" +version = "0.7.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -2767,7 +2767,7 @@ dependencies = [ [[package]] name = "itsgoin-desktop" -version = "0.7.0" +version = "0.7.1" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 8515386..98bd655 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-cli" -version = "0.7.0" +version = "0.7.1" edition = "2021" [[bin]] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 67d7750..4852a7e 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-core" -version = "0.7.0" +version = "0.7.1" edition = "2021" [dependencies] diff --git a/crates/tauri-app/Cargo.toml b/crates/tauri-app/Cargo.toml index d7bc90c..fbb8b23 100644 --- a/crates/tauri-app/Cargo.toml +++ b/crates/tauri-app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-desktop" -version = "0.7.0" +version = "0.7.1" edition = "2021" [lib] diff --git a/crates/tauri-app/tauri.conf.json b/crates/tauri-app/tauri.conf.json index f3aaa93..96bdf58 100644 --- a/crates/tauri-app/tauri.conf.json +++ b/crates/tauri-app/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "itsgoin", - "version": "0.7.0", + "version": "0.7.1", "identifier": "com.itsgoin.app", "build": { "frontendDist": "../../frontend", diff --git a/website/download.html b/website/download.html index c3222ad..e33f53a 100644 --- a/website/download.html +++ b/website/download.html @@ -46,6 +46,39 @@

v0.5.3 is kept online only as an upgrade bridge — it no longer connects to the live network.

+

v0.7.1 — May 15, 2026

+

UI polish + bug-fix pass on top of v0.7.0's FoF gating. Default post visibility is now Extended Friends (FoF). New Friend 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.

+ + + +
    +
  • Default visibility is Extended Friends (FoF). Public is one click away when you want it; encrypted-by-default is the new posture.
  • +
  • Friend button on the bio modal: follow + vouch in one tap. "Follow only" stays available for the watch-but-don't-trust case. "Unfriend" reverses both.
  • +
  • Network Identity → Device Address. UI rename for clarity; the device's QUIC endpoint is distinct from your posting personas. Backend command names are unchanged.
  • +
  • Settings — Move to another device. Export-personas / import-from-another-device buttons get their own labeled section with a plain-English explainer.
  • +
  • Bug fix: profile display name update. Renaming the auto-generated first persona now propagates everywhere (the posting-identities table was previously stuck on the original empty name).
  • +
  • Bug fix: redundancy panel. 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.
  • +
  • Bug fix: My Posts horizontal-scroll regression. 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.
  • +
+

v0.7.1 is wire-compatible with v0.7.0. UI/UX only.

+

v0.7.0 — May 15, 2026

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, FoFClosed). 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.

From 4706e81603d1ec779c141451e1a25614ddd4ea6d Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Fri, 15 May 2026 11:03:39 -0600 Subject: [PATCH 8/9] =?UTF-8?q?feat:=20v0.7.2=20=E2=80=94=20portmapper=20(?= =?UTF-8?q?UPnP+PCP+NAT-PMP),=20session-relay=20opt-in,=20URL=20Phase=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Network/reachability improvements + a relay-privacy fix. Wire-compatible with v0.7.0/v0.7.1; no protocol changes. - Replace hand-rolled UPnP (igd-next) with the portmapper crate. All three protocols (UPnP-IGD / NAT-PMP / PCP) run in parallel, auto-renew internally. PCP adds IPv6 firewall pinholes and works on iOS without the multicast entitlement. Pulled in transitively via iroh already, no net dep growth. - Android: UPnP/PCP/NAT-PMP attempted on WiFi/Ethernet with a WifiManager.MulticastLock acquired for the lifetime of the mapping. Cellular skipped early (no UPnP/PCP gateway, avoid 3s discovery waste). - TCP port-mapping gate removed for mobile — phones with permissive NAT can now serve HTTP for direct browser fetches. - Anchor reachability watcher (bidirectional): clears is_anchor after >5min of no port mapping; restores it when the mapping comes back. Network roams self-heal without restart. Mobile never auto-anchors. - Session relay opt-in restored. relay.session_relay_enabled setting defaults OFF (anchors included — servers shouldn't silently burn bandwidth either). Gates both serving (can_accept_relay_pipe) and using (auto-fallback in node.rs). UI toggle in Settings. Relay-style signaling (RelayIntroduce / worm_lookup / N1-N3 shares) unaffected. - URL Phase 1: share links now contain only the post ID (itsgoin.net/p/). Anchor handler already supported post-ID-only URLs (author was optional); just dropped the author hex from the generator. Older URLs with author hex continue to work. - Quick app close button in header (with confirm) — useful for stopping network activity between sessions on mobile. - JNI null-pointer guards on ndk_context handles in android_wifi.rs. MEMORY rule sharpened to distinguish session relay (byte pipe, opt-in) from relay-style signaling/discovery (always on). Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 10 +- crates/cli/Cargo.toml | 2 +- crates/core/Cargo.toml | 8 +- crates/core/src/android_wifi.rs | 215 +++++++++++++++++ crates/core/src/connection.rs | 55 ++++- crates/core/src/lib.rs | 2 + crates/core/src/network.rs | 155 ++++++++++--- crates/core/src/node.rs | 90 +++---- crates/core/src/upnp.rs | 386 +++++++++++++------------------ crates/tauri-app/Cargo.toml | 2 +- crates/tauri-app/src/lib.rs | 27 +++ crates/tauri-app/tauri.conf.json | 2 +- frontend/app.js | 20 ++ frontend/index.html | 12 + frontend/style.css | 3 + website/download.html | 47 ++-- 16 files changed, 696 insertions(+), 340 deletions(-) create mode 100644 crates/core/src/android_wifi.rs diff --git a/Cargo.lock b/Cargo.lock index 8b5b9a2..ebbbd6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2732,7 +2732,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "itsgoin-cli" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "hex", @@ -2744,7 +2744,7 @@ dependencies = [ [[package]] name = "itsgoin-core" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "base64 0.22.1", @@ -2753,8 +2753,10 @@ dependencies = [ "curve25519-dalek", "ed25519-dalek", "hex", - "igd-next", "iroh", + "jni", + "ndk-context", + "portmapper", "rand 0.9.2", "rusqlite", "serde", @@ -2767,7 +2769,7 @@ dependencies = [ [[package]] name = "itsgoin-desktop" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 98bd655..db2ce1a 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-cli" -version = "0.7.1" +version = "0.7.2" edition = "2021" [[bin]] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 4852a7e..5bf29e4 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-core" -version = "0.7.1" +version = "0.7.2" edition = "2021" [dependencies] @@ -19,7 +19,11 @@ ed25519-dalek = { version = "=3.0.0-pre.1", features = ["rand_core", "zeroize"] chacha20poly1305 = "0.10" base64 = "0.22" zip = { version = "2", default-features = false, features = ["deflate"] } -igd-next = { version = "0.16", features = ["tokio"] } +portmapper = "0.14" + +[target.'cfg(target_os = "android")'.dependencies] +jni = "0.21" +ndk-context = "0.1" [dev-dependencies] tempfile = "3" diff --git a/crates/core/src/android_wifi.rs b/crates/core/src/android_wifi.rs new file mode 100644 index 0000000..f355bd3 --- /dev/null +++ b/crates/core/src/android_wifi.rs @@ -0,0 +1,215 @@ +//! Android-only helpers for WiFi detection and MulticastLock acquisition. +//! +//! SSDP discovery (UPnP) requires receiving multicast UDP on 239.255.255.250:1900. +//! Android filters incoming multicast unless a WifiManager.MulticastLock is held. +//! These helpers acquire the lock for the duration of an SSDP attempt. +//! +//! Cellular networks (CGNAT) almost never expose UPnP/PCP gateways, so we gate +//! UPnP attempts on "is on WiFi" to avoid wasting a 3s discovery timeout on +//! every cellular startup. + +#![cfg(target_os = "android")] + +use jni::objects::{JObject, JString, JValue}; +use jni::JavaVM; +use tracing::{debug, warn}; + +/// Returns true if the active network is WiFi (or Ethernet). +/// Returns false on cellular, no-network, or any error. +pub fn is_on_wifi() -> bool { + match check_wifi_inner() { + Ok(v) => v, + Err(e) => { + debug!("Android WiFi check failed: {}", e); + false + } + } +} + +fn check_wifi_inner() -> Result { + 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 { + match Self::acquire_inner(tag) { + Ok(g) => Some(g), + Err(e) => { + debug!("MulticastLock acquire failed: {}", e); + None + } + } + } + + fn acquire_inner(tag: &str) -> Result { + 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); + } + } +} diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index 9a022a5..0892455 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -626,6 +626,11 @@ pub struct ConnectionManager { active_relay_pipes: Arc, /// 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, /// Device profile (for resource limits) #[allow(dead_code)] device_profile: DeviceProfile, @@ -707,11 +712,13 @@ impl ConnectionManager { bind_addr: Option, nat_type: crate::types::NatType, nat_mapping: crate::types::NatMapping, + session_relay_enabled: bool, ) -> Self { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; + let session_relay_enabled = Arc::new(AtomicBool::new(session_relay_enabled)); Self { connections: HashMap::new(), endpoint, @@ -732,6 +739,7 @@ impl ConnectionManager { seen_intros: HashMap::new(), active_relay_pipes: Arc::new(AtomicU64::new(0)), max_relay_pipes: profile.max_relay_pipes(), + session_relay_enabled, device_profile: profile, unreachable_peers: HashMap::new(), referral_list: HashMap::new(), @@ -3631,11 +3639,28 @@ impl ConnectionManager { &self.active_relay_pipes } - /// Check if we can accept more relay pipes. + /// Check if we can accept more relay pipes. Gated on user opt-in + /// (`relay.session_relay_enabled`) — returns false if the user has + /// not enabled serving as a session relay, regardless of capacity. pub fn can_accept_relay_pipe(&self) -> bool { + if !self.session_relay_enabled.load(Ordering::Relaxed) { + return false; + } self.active_relay_pipes.load(Ordering::Relaxed) < self.max_relay_pipes as u64 } + /// Whether the user has opted in to session relay (both serving and using). + pub fn is_session_relay_enabled(&self) -> bool { + self.session_relay_enabled.load(Ordering::Relaxed) + } + + /// Update the session-relay opt-in flag. The caller is responsible for + /// persisting the setting to storage; this only updates the in-memory + /// flag that gates the relay accept and auto-use paths. + pub fn set_session_relay_enabled(&self, enabled: bool) { + self.session_relay_enabled.store(enabled, Ordering::Relaxed); + } + /// Get our node ID. pub fn our_node_id(&self) -> &NodeId { &self.our_node_id @@ -6581,6 +6606,13 @@ pub enum ConnCommand { CanAcceptRelayPipe { reply: oneshot::Sender, }, + IsSessionRelayEnabled { + reply: oneshot::Sender, + }, + SetSessionRelayEnabled { + enabled: bool, + reply: oneshot::Sender<()>, + }, BuildAnchorAdvertisedAddr { reply: oneshot::Sender>, }, @@ -6978,6 +7010,18 @@ impl ConnHandle { rx.await.unwrap_or(false) } + pub async fn is_session_relay_enabled(&self) -> bool { + let (tx, rx) = oneshot::channel(); + let _ = self.tx.send(ConnCommand::IsSessionRelayEnabled { reply: tx }).await; + rx.await.unwrap_or(false) + } + + pub async fn set_session_relay_enabled(&self, enabled: bool) { + let (tx, rx) = oneshot::channel(); + let _ = self.tx.send(ConnCommand::SetSessionRelayEnabled { enabled, reply: tx }).await; + let _ = rx.await; + } + pub async fn build_anchor_advertised_addr(&self) -> Option { let (tx, rx) = oneshot::channel(); let _ = self.tx.send(ConnCommand::BuildAnchorAdvertisedAddr { reply: tx }).await; @@ -7902,6 +7946,15 @@ impl ConnectionActor { let cm = self.cm.lock().await; let _ = reply.send(cm.can_accept_relay_pipe()); } + ConnCommand::IsSessionRelayEnabled { reply } => { + let cm = self.cm.lock().await; + let _ = reply.send(cm.is_session_relay_enabled()); + } + ConnCommand::SetSessionRelayEnabled { enabled, reply } => { + let cm = self.cm.lock().await; + cm.set_session_relay_enabled(enabled); + let _ = reply.send(()); + } ConnCommand::BuildAnchorAdvertisedAddr { reply } => { let cm = self.cm.lock().await; let _ = reply.send(cm.build_anchor_advertised_addr()); diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index a4e6d7f..3c679a0 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,4 +1,6 @@ pub mod activity; +#[cfg(target_os = "android")] +pub mod android_wifi; pub mod blob; pub mod connection; pub mod content; diff --git a/crates/core/src/network.rs b/crates/core/src/network.rs index 282a576..6466eff 100644 --- a/crates/core/src/network.rs +++ b/crates/core/src/network.rs @@ -35,10 +35,12 @@ pub struct Network { /// Growth loop signal sender (set by start_growth_loop) growth_tx: tokio::sync::Mutex>>, activity_log: Arc>, - /// UPnP mapping result (None if no mapping or on mobile) - upnp_mapping: Option, - /// Whether UPnP TCP mapping succeeded (for HTTP serving) - has_upnp_tcp: bool, + /// UDP port mapping (UPnP/NAT-PMP/PCP) for the QUIC socket. + /// Holding this keeps the auto-renewing portmapper service alive. + upnp_mapping: Option, + /// TCP port mapping for HTTP serving, if available. Holding it keeps + /// the lease renewed by the portmapper background service. + tcp_mapping: Option, /// Whether this node has a public IPv6 address has_public_v6: bool, /// Stable bind address (from --bind flag), passed to ConnectionManager for anchor advertised address @@ -120,12 +122,18 @@ impl Network { let our_node_id = *endpoint.id().as_bytes(); - // Best-effort UPnP port mapping (desktop only, skip if --bind was used) + // Best-effort UDP port mapping (UPnP/NAT-PMP/PCP via portmapper). + // Skip if --bind was explicit (operator chose a fixed external port). + // The portmapper service auto-renews internally; we hold the + // PortMapping handle for the lifetime of the network. On Android the + // upnp module gates on active-network-is-WiFi and acquires a + // MulticastLock around UPnP/SSDP discovery for the lifetime of the + // mapping. iOS works for PCP and NAT-PMP without entitlement. let is_mobile = cfg!(target_os = "android") || cfg!(target_os = "ios"); - let upnp_mapping = if !is_mobile && bind_addr.is_none() { + let upnp_mapping = if bind_addr.is_none() { let bound_port = endpoint.bound_sockets().first() .map(|s| s.port()).unwrap_or(0); - crate::upnp::try_upnp_mapping(bound_port).await + crate::upnp::PortMapping::try_udp(bound_port).await } else { None }; @@ -215,6 +223,14 @@ impl Network { } let upnp_external_addr = upnp_mapping.as_ref().map(|m| m.external_addr); + let session_relay_enabled = { + let s = storage.get().await; + s.get_setting("relay.session_relay_enabled") + .ok() + .flatten() + .map(|v| v == "true") + .unwrap_or(false) + }; let conn_mgr = ConnectionManager::new( endpoint.clone(), Arc::clone(&storage), @@ -228,18 +244,26 @@ impl Network { bind_addr, nat_type, nat_mapping, + session_relay_enabled, ); let conn_mgr = Arc::new(Mutex::new(conn_mgr)); // Spawn actor wrapping the same Arc> (Phase 1: additive) let conn_handle = ConnectionActor::spawn_with_arc(Arc::clone(&conn_mgr)).await; - // TCP UPnP mapping for HTTP post delivery (only if UDP UPnP succeeded) - let has_upnp_tcp = if let Some(ref mapping) = upnp_mapping { - crate::upnp::try_upnp_tcp_mapping(mapping.local_port, mapping.external_addr.port()).await + // TCP port mapping for HTTP post delivery. Run on every platform — + // mobile devices with permissive NAT (UPnP/PCP-TCP working) can serve + // HTTP for direct browser fetches. We attempt independently of the + // UDP mapping result because the protocols are separate calls in + // portmapper. + let tcp_mapping = if bind_addr.is_none() { + let bound_port = endpoint.bound_sockets().first() + .map(|s| s.port()).unwrap_or(0); + crate::upnp::PortMapping::try_tcp(bound_port).await } else { - false + None }; + let has_upnp_tcp = tcp_mapping.is_some(); info!( node_id = %endpoint.id(), @@ -259,6 +283,79 @@ impl Network { info!(role = %device_role, "CDN replication role determined"); conn_handle.set_device_role(device_role); + // Anchor reachability watcher: tracks the UDP port mapping over time + // and adjusts anchor mode without restart. + // - Mapping lost (None) for >5 min → clear anchor mode (don't keep + // advertising an unreachable address). Replaces the old + // "3 consecutive UPnP renewal failures" logic. + // - Mapping restored (None → Some) → re-evaluate auto-anchor. If + // this node qualifies (desktop with the new mapping in hand), set + // anchor back on. Mobile never auto-anchors regardless. + // The watcher exits when the watch channel closes (i.e., when the + // PortMapping is dropped at network shutdown). + if let Some(ref mapping) = upnp_mapping { + let mut rx = mapping.watch_external(); + let is_anchor_w = Arc::clone(&is_anchor); + let alog_w = Arc::clone(&activity_log); + tokio::spawn(async move { + let mut down_since: Option = None; + let threshold = std::time::Duration::from_secs(300); + loop { + if rx.changed().await.is_err() { + // Channel closed — mapping was dropped, watcher done. + return; + } + let current = *rx.borrow_and_update(); + match current { + Some(addr) => { + // Mapping restored. If we'd lost anchor due to a + // previous drop, restore it now. Skip on mobile + // (cellular IPs look public but aren't anchorable). + let recovered = down_since.is_some(); + down_since = None; + if recovered && !is_mobile && !is_anchor_w.load(Ordering::Relaxed) { + is_anchor_w.store(true, Ordering::Relaxed); + if let Ok(mut log) = alog_w.try_lock() { + log.log( + ActivityLevel::Info, + ActivityCategory::Connection, + format!( + "Port mapping restored ({}), anchor mode re-enabled", + addr + ), + None, + ); + } + info!(external = %addr, "Port mapping restored — anchor mode re-enabled"); + } + } + None => { + let now = std::time::Instant::now(); + let t = *down_since.get_or_insert(now); + if now.duration_since(t) > threshold + && is_anchor_w.load(Ordering::Relaxed) + { + is_anchor_w.store(false, Ordering::Relaxed); + if let Ok(mut log) = alog_w.try_lock() { + log.log( + ActivityLevel::Warn, + ActivityCategory::Connection, + "Port mapping lost >5min, anchor mode cleared" + .into(), + None, + ); + } + warn!( + "Port mapping lost for >5min — anchor mode cleared" + ); + // Don't return — keep watching for recovery so we can re-anchor. + } + } + } + } + }); + } + Ok(Self { endpoint, storage, @@ -269,7 +366,7 @@ impl Network { growth_tx: tokio::sync::Mutex::new(None), activity_log, upnp_mapping, - has_upnp_tcp, + tcp_mapping, has_public_v6, bind_addr, device_role, @@ -333,8 +430,8 @@ impl Network { addrs } - /// Get the UPnP mapping, if one was successfully acquired. - pub fn upnp_mapping(&self) -> Option<&crate::upnp::UpnpMapping> { + /// Get the active UDP port mapping (UPnP/NAT-PMP/PCP), if any. + pub fn upnp_mapping(&self) -> Option<&crate::upnp::PortMapping> { self.upnp_mapping.as_ref() } @@ -359,7 +456,7 @@ impl Network { /// Whether this node can serve HTTP (has TCP reachability). pub fn is_http_capable(&self) -> bool { - self.has_upnp_tcp || self.has_public_v6 || self.bind_addr.is_some() + self.tcp_mapping.is_some() || self.has_public_v6 || self.bind_addr.is_some() } /// Get the port to bind the HTTP TCP listener on (same as QUIC). @@ -373,9 +470,9 @@ impl Network { } } - /// Whether UPnP TCP mapping is active. + /// Whether the TCP port mapping for HTTP serving is active. pub fn has_upnp_tcp(&self) -> bool { - self.has_upnp_tcp + self.tcp_mapping.is_some() } /// Whether this node has a public IPv6 address. @@ -383,15 +480,19 @@ impl Network { self.has_public_v6 } - /// Whether this node has UPnP mapping (UDP or TCP). + /// Whether this node has a port mapping (UDP or TCP) from any of + /// UPnP-IGD / NAT-PMP / PCP. pub fn has_upnp(&self) -> bool { - self.upnp_mapping.is_some() || self.has_upnp_tcp + self.upnp_mapping.is_some() || self.tcp_mapping.is_some() } /// Get external HTTP address string for InitialExchange advertisement. pub fn http_addr(&self) -> Option { - if let Some(ref mapping) = self.upnp_mapping { + // Prefer an explicit TCP mapping (UPnP/PCP/NAT-PMP) — that's the address + // a remote browser can reach for HTTP serving. The UDP mapping is for + // QUIC, not HTTP. + if let Some(ref mapping) = self.tcp_mapping { return Some(mapping.external_addr.to_string()); } if let Some(bind) = self.bind_addr { @@ -2152,18 +2253,20 @@ impl Network { pub async fn shutdown(self) -> anyhow::Result<()> { // Remove UPnP port mapping before closing endpoint - if let Some(ref mapping) = self.upnp_mapping { - crate::upnp::remove_upnp_mapping(mapping.external_addr.port()).await; - } + // Dropping the PortMapping triggers the portmapper Client's + // AbortOnDropHandle and stops the renewal task. We don't explicitly + // release here because `self` doesn't move; the field will drop + // naturally when the Network goes out of scope. self.endpoint.close().await; Ok(()) } /// Shutdown via Arc reference — closes the endpoint, causing all background tasks to exit. pub async fn shutdown_ref(&self) { - if let Some(ref mapping) = self.upnp_mapping { - crate::upnp::remove_upnp_mapping(mapping.external_addr.port()).await; - } + // Dropping the PortMapping triggers the portmapper Client's + // AbortOnDropHandle and stops the renewal task. We don't explicitly + // release here because `self` doesn't move; the field will drop + // naturally when the Network goes out of scope. self.endpoint.close().await; } diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 0450ab8..e6ed36d 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -3692,8 +3692,14 @@ impl Node { } } - // Step 7: Session relay fallback — if intro was accepted but hole punch failed - if let (Some(intro_id), Some(relay_peer)) = (last_intro_id, last_relay_peer) { + // Step 7: Session relay fallback — only if BOTH the introducer + // signaled relay availability AND this node has opted in to + // using session relay (`relay.session_relay_enabled`). Default + // is opt-out: hole-punch failure does NOT silently fall back + // to byte-relaying through a third party. + if !self.network.conn_handle().is_session_relay_enabled().await { + debug!(target = hex::encode(peer_id), "Session relay opt-out — skipping relay fallback"); + } else if let (Some(intro_id), Some(relay_peer)) = (last_intro_id, last_relay_peer) { if last_relay_available { info!( target = hex::encode(peer_id), @@ -4577,44 +4583,16 @@ impl Node { }) } - /// Start UPnP lease renewal cycle. Renews every lease_secs/2. - /// On 3 consecutive failures: clears is_anchor and logs a warning. + /// No-op since v0.7.2: the `portmapper::Client` held inside the active + /// `PortMapping` auto-renews internally in its own background service. + /// Retained for API compatibility with callers in CLI and Tauri until + /// they're cleaned up. Returns `None`. + /// + /// TODO(v0.7.x): wire a watcher on `mapping.watch_external()` to clear + /// anchor mode if the external address stays `None` for more than ~5min + /// (parity with the old "3 renewal failures" behavior). pub fn start_upnp_renewal_cycle(&self) -> Option> { - let mapping = self.network.upnp_mapping()?; - let local_port = mapping.local_port; - let external_port = mapping.external_addr.port(); - let interval_secs = (mapping.lease_secs / 2) as u64; - let network = Arc::clone(&self.network); - let alog = Arc::clone(&self.activity_log); - - Some(tokio::spawn(async move { - let mut interval = - tokio::time::interval(std::time::Duration::from_secs(interval_secs)); - let mut consecutive_failures: u32 = 0; - loop { - interval.tick().await; - if crate::upnp::renew_upnp_mapping(local_port, external_port).await { - consecutive_failures = 0; - debug!("UPnP: lease renewed (port {})", external_port); - } else { - consecutive_failures += 1; - warn!("UPnP: renewal failed ({}/3)", consecutive_failures); - if consecutive_failures >= 3 { - network.clear_anchor(); - if let Ok(mut log) = alog.try_lock() { - log.log( - ActivityLevel::Warn, - ActivityCategory::Connection, - "UPnP lease lost after 3 renewal failures, auto-anchor disabled".into(), - None, - ); - } - warn!("UPnP: 3 consecutive renewal failures, auto-anchor disabled"); - return; // stop the cycle - } - } - } - })) + None } // --- HTTP Post Delivery --- @@ -4669,34 +4647,21 @@ impl Node { }) } - /// Start UPnP TCP lease renewal cycle alongside the UDP renewal. + /// No-op since v0.7.2 — the TCP `portmapper::Client` auto-renews internally. pub fn start_upnp_tcp_renewal_cycle(&self) -> Option> { - if !self.network.has_upnp_tcp() { - return None; - } - let mapping = self.network.upnp_mapping()?; - let local_port = mapping.local_port; - let external_port = mapping.external_addr.port(); - let interval_secs = (mapping.lease_secs / 2) as u64; - - Some(tokio::spawn(async move { - let mut interval = - tokio::time::interval(std::time::Duration::from_secs(interval_secs)); - loop { - interval.tick().await; - if !crate::upnp::renew_upnp_tcp_mapping(local_port, external_port).await { - warn!("UPnP: TCP lease renewal failed"); - // Don't stop the cycle — TCP is best-effort - } - } - })) + None } /// Generate a share link URL for a public post. /// Returns None if post is not public or not found. + /// + /// URL Phase 1 (v0.7.2): the link contains only the post ID — no author + /// hex, no node addresses. The receiving anchor (itsgoin.net) does the + /// holder lookup itself and serves via redirect or QUIC-proxy fallback. + /// Older URLs with `/{post_hex}/{author_hex}` continue to work — the + /// web handler parses the author hex as optional. pub async fn generate_share_link(&self, post_id: &PostId) -> anyhow::Result> { - // Look up the post to verify it's public and get the author - let (post, visibility) = { + let (_post, visibility) = { let store = self.storage.get().await; match store.get_post_with_visibility(post_id)? { Some(pv) => pv, @@ -4709,8 +4674,7 @@ impl Node { } let post_hex = hex::encode(post_id); - let author_hex = hex::encode(post.author); - Ok(Some(format!("https://itsgoin.net/p/{}/{}", post_hex, author_hex))) + Ok(Some(format!("https://itsgoin.net/p/{}", post_hex))) } // --- Engagement API --- diff --git a/crates/core/src/upnp.rs b/crates/core/src/upnp.rs index df4f445..643a985 100644 --- a/crates/core/src/upnp.rs +++ b/crates/core/src/upnp.rs @@ -1,243 +1,181 @@ -//! Best-effort UPnP port mapping for NAT traversal. -//! Skipped entirely on mobile platforms where UPnP is unsupported. +//! NAT port mapping via UPnP-IGD, NAT-PMP, and PCP. +//! +//! Wraps the `portmapper` crate (also used internally by iroh) which runs +//! all three protocols in parallel and auto-renews the lease in a background +//! task. This module is named `upnp` for historical reasons — by v0.7.2 it +//! covers more than UPnP. +//! +//! ## Protocols +//! - **UPnP-IGD** — long-standing consumer-router default. Discovery uses +//! SSDP multicast on 239.255.255.250:1900. Behavior on Hold harbor routers +//! varies; many ship with UPnP disabled by default. +//! - **NAT-PMP** (RFC 6886) — Apple lineage; widespread on routers that +//! ever shipped Bonjour. Unicast to the gateway on UDP/5351. +//! - **PCP** (RFC 6887) — modern IETF-track successor to NAT-PMP. Unicast +//! on UDP/5351. Supports both IPv4 NAT mapping and IPv6 firewall pinholes +//! (the latter via `AddPinhole`-shaped requests). Increasingly common in +//! modern routers. +//! +//! All three are attempted in parallel by portmapper; the first one to +//! respond wins. PCP responses arrive sub-second when present; SSDP wakes +//! up in 1–3s. +//! +//! ## Per-platform contract +//! | Platform | UPnP-IGD | NAT-PMP | PCP | TCP for HTTP | +//! |---|---|---|---|---| +//! | Linux/macOS/Windows | ✓ | ✓ | ✓ | ✓ | +//! | Android (WiFi/Ethernet) | ✓ (with MulticastLock) | ✓ | ✓ | ✓ | +//! | Android (cellular) | ✗ (skipped early) | ✗ | ✗ | ✗ | +//! | iOS | ✗ (without `com.apple.developer.networking.multicast` entitlement) | ✓ | ✓ | ✓ | +//! +//! ## Android specifics +//! `try_udp` / `try_tcp` first check `crate::android_wifi::is_on_wifi()`. +//! If not on WiFi/Ethernet (cellular), returns `None` immediately — cellular +//! networks almost never expose any of these protocols, and a discovery +//! timeout would waste ~3s every startup. +//! +//! When on WiFi, a `WifiManager.MulticastLock` is acquired and held for the +//! lifetime of the resulting `PortMapping`. Without the lock Android filters +//! the SSDP multicast responses; PCP/NAT-PMP work without it but UPnP-IGD +//! would never complete. The lock is released on Drop. +//! +//! ## iOS specifics +//! Until Apple grants the multicast entitlement, UPnP-IGD will silently fail +//! (no SSDP responses delivered to the socket). The unicast protocols PCP +//! and NAT-PMP succeed without any entitlement and cover most modern home +//! routers, so iOS gets reasonable coverage today. +//! +//! ## Renewal model +//! Auto-renewal happens inside `portmapper::Client`'s internal task — there +//! is **no** external renewal cycle to schedule. The `PortMapping` value +//! owns the client; while it's held, the mapping stays alive. Dropping it +//! aborts the renewal task (via `AbortOnDropHandle`) and stops keeping the +//! mapping alive at the gateway. `release(&self)` triggers an explicit +//! deactivation message before drop for cleaner shutdown. +//! +//! ## Anchor reachability watcher (bidirectional) +//! `Network::start` spawns a task that observes the UDP mapping's +//! `watch_external()` channel and adjusts anchor mode at runtime: +//! - **Mapping lost** for >5 min → clear `is_anchor`. The node stops +//! advertising itself as an anchor at the now-stale external address. +//! - **Mapping restored** (None → Some) → re-evaluate auto-anchor. On +//! non-mobile devices the anchor flag is set back on so the node +//! re-joins the anchor set without a restart. +//! +//! Network roams (e.g., leaving a UPnP-capable WiFi and joining a new one) +//! self-heal. Mobile devices never auto-anchor regardless — cellular IPs +//! look public but sit behind CGNAT. use std::net::SocketAddr; -#[cfg(not(any(target_os = "android", target_os = "ios")))] -use tracing::{info, debug}; +use std::num::NonZeroU16; +use std::time::Duration; +use tracing::{debug, info, warn}; -/// Result of a successful UPnP port mapping. -pub struct UpnpMapping { +/// An active port mapping. While this value is held, the underlying +/// `portmapper::Client` keeps the lease alive in a background task. +/// Dropping it releases the mapping. +pub struct PortMapping { pub external_addr: SocketAddr, - pub lease_secs: u32, pub local_port: u16, + #[allow(dead_code)] // service kept alive by holding the client + client: portmapper::Client, + #[cfg(target_os = "android")] + #[allow(dead_code)] // lock kept alive for the lifetime of the mapping + mcast_lock: Option, } -/// 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 { - use igd_next::SearchOptions; +impl PortMapping { + /// Best-effort UDP port mapping for the given local QUIC port. + /// On Android, requires an active WiFi/Ethernet connection — returns + /// `None` immediately on cellular. Waits up to 3 seconds for any of the + /// three protocols (PCP / NAT-PMP / UPnP-IGD) to return a mapping. + pub async fn try_udp(local_port: u16) -> Option { + Self::try_for_protocol(local_port, portmapper::Protocol::Udp).await + } - let search_opts = SearchOptions { - timeout: Some(std::time::Duration::from_secs(3)), - ..Default::default() - }; + /// Best-effort TCP port mapping for HTTP serving on the given local port. + /// Same platform gating as UDP. Used to make this node HTTP-reachable + /// for browser-shaped fetches of public posts. + pub async fn try_tcp(local_port: u16) -> Option { + Self::try_for_protocol(local_port, portmapper::Protocol::Tcp).await + } - let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await { - Ok(gw) => gw, - Err(e) => { - debug!("UPnP gateway discovery failed (expected behind non-UPnP router): {}", e); - return None; + async fn try_for_protocol(local_port: u16, protocol: portmapper::Protocol) -> Option { + let port = NonZeroU16::new(local_port)?; + + #[cfg(target_os = "android")] + { + if !crate::android_wifi::is_on_wifi() { + debug!("Port mapping: skipping (not on WiFi/Ethernet)"); + return None; + } } - }; - let external_ip = match gateway.get_external_ip().await { - Ok(ip) => ip, - Err(e) => { - debug!("UPnP: could not get external IP: {}", e); - return None; - } - }; + #[cfg(target_os = "android")] + let mcast_lock = crate::android_wifi::MulticastLockGuard::acquire("itsgoin-ssdp"); - // 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 + 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); - // 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); + // Wait up to 3 seconds for any protocol to produce a mapping. + let mut watch = client.watch_external_address(); + let external = match tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let Some(addr) = *watch.borrow_and_update() { + return Some(addr); + } + if watch.changed().await.is_err() { return None; } } - } - }; - - let external_addr = SocketAddr::new(external_ip, external_port); - info!("UPnP: mapped {}:{} → :{}", external_ip, external_port, local_port); - - Some(UpnpMapping { - external_addr, - lease_secs, - local_port, - }) -} - -#[cfg(any(target_os = "android", target_os = "ios"))] -pub async fn try_upnp_mapping(_local_port: u16) -> Option { - None -} - -/// Renew an existing UPnP lease. Returns true on success. -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub async fn renew_upnp_mapping(local_port: u16, external_port: u16) -> bool { - use igd_next::SearchOptions; - - let search_opts = SearchOptions { - timeout: Some(std::time::Duration::from_secs(3)), - ..Default::default() - }; - - let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await { - Ok(gw) => gw, - Err(_) => return false, - }; - - let local_addr = SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), local_port); - gateway.add_port( - igd_next::PortMappingProtocol::UDP, - external_port, - local_addr, - 1800, - "itsgoin", - ).await.is_ok() -} - -#[cfg(any(target_os = "android", target_os = "ios"))] -pub async fn renew_upnp_mapping(_local_port: u16, _external_port: u16) -> bool { - false -} - -/// Remove UPnP mapping on shutdown. Best-effort, errors are silently ignored. -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub async fn remove_upnp_mapping(external_port: u16) { - use igd_next::SearchOptions; - - let search_opts = SearchOptions { - timeout: Some(std::time::Duration::from_secs(3)), - ..Default::default() - }; - - if let Ok(gateway) = igd_next::aio::tokio::search_gateway(search_opts).await { - let _ = gateway.remove_port(igd_next::PortMappingProtocol::UDP, external_port).await; - info!("UPnP: removed port mapping for external port {}", external_port); - } -} - -#[cfg(any(target_os = "android", target_os = "ios"))] -pub async fn remove_upnp_mapping(_external_port: u16) {} - -// --- TCP port mapping (for HTTP post delivery) --- - -/// Best-effort UPnP TCP port mapping on the same port as QUIC UDP. -/// Returns true on success. Reuses the already-discovered gateway. -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub async fn try_upnp_tcp_mapping(local_port: u16, external_port: u16) -> bool { - use igd_next::SearchOptions; - - let search_opts = SearchOptions { - timeout: Some(std::time::Duration::from_secs(3)), - ..Default::default() - }; - - let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await { - Ok(gw) => gw, - Err(_) => return false, - }; - - let local_addr = SocketAddr::new( - std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), - local_port, - ); - - match gateway - .add_port( - igd_next::PortMappingProtocol::TCP, - external_port, - local_addr, - 1800, - "itsgoin-http", - ) + }) .await - { - Ok(()) => { - info!("UPnP: TCP port {} mapped for HTTP serving", external_port); - true - } - Err(e) => { - debug!("UPnP: TCP port mapping failed (non-fatal): {}", e); - false - } + { + 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" + ); + + Some(PortMapping { + external_addr: SocketAddr::V4(external), + local_port, + client, + #[cfg(target_os = "android")] + mcast_lock, + }) + } + + /// Watch for changes to the external address. Useful when the underlying + /// network changes (e.g., mobile WiFi roam) — the mapping may move to a + /// new public IP/port. + pub fn watch_external(&self) -> tokio::sync::watch::Receiver> { + self.client.watch_external_address() + } + + /// Explicitly request the portmapper service to release the gateway + /// mapping. The actual cleanup completes when the `PortMapping` is + /// dropped (the internal service handle is abort-on-drop). Allows + /// callers to signal "release now" before the value goes out of scope. + pub fn release(&self) { + self.client.deactivate(); } } - -#[cfg(any(target_os = "android", target_os = "ios"))] -pub async fn try_upnp_tcp_mapping(_local_port: u16, _external_port: u16) -> bool { - false -} - -/// Renew an existing UPnP TCP lease. Returns true on success. -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub async fn renew_upnp_tcp_mapping(local_port: u16, external_port: u16) -> bool { - use igd_next::SearchOptions; - - let search_opts = SearchOptions { - timeout: Some(std::time::Duration::from_secs(3)), - ..Default::default() - }; - - let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await { - Ok(gw) => gw, - Err(_) => return false, - }; - - let local_addr = SocketAddr::new( - std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), - local_port, - ); - gateway - .add_port( - igd_next::PortMappingProtocol::TCP, - external_port, - local_addr, - 1800, - "itsgoin-http", - ) - .await - .is_ok() -} - -#[cfg(any(target_os = "android", target_os = "ios"))] -pub async fn renew_upnp_tcp_mapping(_local_port: u16, _external_port: u16) -> bool { - false -} - -/// Remove UPnP TCP mapping on shutdown. -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub async fn remove_upnp_tcp_mapping(external_port: u16) { - use igd_next::SearchOptions; - - let search_opts = SearchOptions { - timeout: Some(std::time::Duration::from_secs(3)), - ..Default::default() - }; - - if let Ok(gateway) = igd_next::aio::tokio::search_gateway(search_opts).await { - let _ = gateway - .remove_port(igd_next::PortMappingProtocol::TCP, external_port) - .await; - info!("UPnP: removed TCP port mapping for port {}", external_port); - } -} - -#[cfg(any(target_os = "android", target_os = "ios"))] -pub async fn remove_upnp_tcp_mapping(_external_port: u16) {} diff --git a/crates/tauri-app/Cargo.toml b/crates/tauri-app/Cargo.toml index fbb8b23..7283ae1 100644 --- a/crates/tauri-app/Cargo.toml +++ b/crates/tauri-app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-desktop" -version = "0.7.1" +version = "0.7.2" edition = "2021" [lib] diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 6246241..a713ceb 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -1142,6 +1142,11 @@ async fn list_vouches_given(state: State<'_, AppNode>) -> Result) -> Result, String> { let node = get_node(&state).await; @@ -1698,6 +1703,25 @@ async fn set_update_channel(state: State<'_, AppNode>, channel: String) -> Resul storage.set_setting("ui_update_channel", &channel).map_err(|e| e.to_string()) } +#[tauri::command] +async fn get_session_relay_enabled(state: State<'_, AppNode>) -> Result { + let node = get_node(&state).await; + Ok(node.network.conn_handle().is_session_relay_enabled().await) +} + +#[tauri::command] +async fn set_session_relay_enabled(state: State<'_, AppNode>, enabled: bool) -> Result<(), String> { + let node = get_node(&state).await; + { + let storage = node.storage.get().await; + storage + .set_setting("relay.session_relay_enabled", if enabled { "true" } else { "false" }) + .map_err(|e| e.to_string())?; + } + node.network.conn_handle().set_session_relay_enabled(enabled).await; + Ok(()) +} + /// Open a URL in the user's default system browser. /// Desktop: spawns the platform opener (xdg-open / open / cmd start). /// Only https:// URLs are accepted to avoid being a generic command exec. @@ -3380,6 +3404,9 @@ pub fn run() { import_as_new_identity, import_as_personas_cmd, import_merge_with_key, + exit_app, + get_session_relay_enabled, + set_session_relay_enabled, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/crates/tauri-app/tauri.conf.json b/crates/tauri-app/tauri.conf.json index 96bdf58..4ec7623 100644 --- a/crates/tauri-app/tauri.conf.json +++ b/crates/tauri-app/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "itsgoin", - "version": "0.7.1", + "version": "0.7.2", "identifier": "com.itsgoin.app", "build": { "frontendDist": "../../frontend", diff --git a/frontend/app.js b/frontend/app.js index 5fc2a2b..7db32cb 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -449,6 +449,12 @@ $('#popover-overlay').addEventListener('click', (e) => { if (e.target === $('#popover-overlay')) closePopover(); }); +$('#close-app-btn').addEventListener('click', async () => { + if (confirm('Close ItsGoin?\n\nStops all network connections to save battery. Reopen the app any time to resume.')) { + try { await invoke('exit_app'); } catch (_) {} + } +}); + function relativeTime(timestampMs) { const now = Date.now(); const diff = now - timestampMs; @@ -4245,6 +4251,20 @@ $('#import-btn').addEventListener('click', () => { overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); }); +(async () => { + const toggle = $('#session-relay-toggle'); + if (!toggle) return; + try { toggle.checked = await invoke('get_session_relay_enabled'); } catch (_) {} + toggle.addEventListener('change', async () => { + try { + await invoke('set_session_relay_enabled', { enabled: toggle.checked }); + } catch (e) { + toggle.checked = !toggle.checked; + alert('Failed to update session relay setting: ' + e); + } + }); +})(); + $('#notifications-btn').addEventListener('click', async () => { // Load current settings const msgVal = await invoke('get_setting', { key: 'notif_messages' }).catch(() => null) || 'on'; diff --git a/frontend/index.html b/frontend/index.html index 7abd8f2..91a18c7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -25,6 +25,7 @@
+
+
+

Session Relay (off by default)

+

+ 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 using other peers as relays and serving as a relay for others. +

+ +
+