diff --git a/docs/agents/reticulum.md b/docs/agents/reticulum.md index c9c54484a..63e121c30 100644 --- a/docs/agents/reticulum.md +++ b/docs/agents/reticulum.md @@ -16,7 +16,7 @@ Deep subsystem reference for AI assistants. Open this when a task touches the Re - **RNode flasher timeouts:** `RNODE_COMMAND_TIMEOUT_MS` (30 s serial), `RNODE_BT_PAIRING_TIMEOUT_MS` (90 s BLE pairing), `ESP32_FLASH_STALL_TIMEOUT_MS` / `NRF52_DFU_STALL_TIMEOUT_MS` (60 s no-progress → `ESP32_FLASH_STALLED` / `NRF52_DFU_STALLED`); humanized via `flasherErrorHumanize.ts` - **Peer aliases / History vs Contacts:** LXMF/Nomad announce names overlay path-table peers; SQLite `reticulum_destinations.last_heard` = History, `is_contact` = Contacts (Save as contact only — inbound/outbound LXMF does **not** auto-add Contacts; sidecar `/contacts` wire rows are History hints unless SQLite `is_contact=1`); default avatars via vendored LXMFace (`lib/reticulum/lxmface.ts`); renderer refresh + `reticulumContactToNodeRecordPreservingLabel` refuse hash-prefix wipes of Chat/`nodeStore` labels; ingest stamps History via `persistReticulumHistoryFromPayload` + `stampHistoryPeer`; SQL upsert guard preserves real names over hash-prefix aliases; destination upsert requires exact 32-hex (lowercase) and omits `favorited` on icon-only patches so favorites/icons survive path/probe refresh - **Stores/lib:** `reticulumIdentityStore.ts` (session-global sidecar identity status shared by `useReticulumSidecarApi` — distinct from identity-scoped `identityStore`), `reticulumPeerStore.ts` (path-table `peers` + `history` + saved `contacts`; soft-TTL reads, forced `?refresh=1`, incremental `peers_updated` route-field patches, 50ms batching, name/appearance preservation, 30s/60s large-mesh poll), `reticulumDiscoveryMapStore.ts`, `reticulumRmapDiscovery.ts`, `reticulumDiscoveryMapLayout.ts`, `nomadNetworkStore.ts`, `rrcHubStore.ts` / `rrcSessionStore.ts` (RRC hubs + multi-hub sessions; hydrate/clear room history via `rrcRoomHistory.ts`; persist → SQLite `rrc_messages` via `rrcMessagePersist.ts` + `ipc/rrc-db-handlers.ts`; prefs in `rrcHubPrefs` / `rrcRoomPrefs` / `rrcRecentRooms`; notifications in `rrcInactiveNotifications` / `rrcMention`); **Remote (rnsh/rncp):** `rncpTransferStore.ts`, `rnshSessionStore.ts`, `reticulumInboundPolicyStore.ts`, `reticulumRemoteAddressStore.ts`, `rncpEnableRequestStore.ts` + lib `remoteSettingsStorage.ts`, `pushRncpListenerPolicy.ts`, `rncpInboundPolicyLists.ts`, `sendRncpRequestEnable.ts`, `rncpRequestEnableRateLimit.ts`, `applyRncpReceiveDestShare.ts` / `rncpReceiveDestSharePending.ts` (mark pending on request-enable; consume on ingest within TTL), `hooks/useRemotePathCapability.ts`, `components/remote/*`; WS events `rmap.discovery`, `lxmf_outbound_status`, `nomadnetwork.node`, `rrc.*`, `rnsh.*` / `rncp.*` in `useReticulumRuntime` (sidecar also emits `nomad.serving_start` / `nomad.serving_stop`; renderer polls serving status via HTTP, not those WS events) -- **LXMF outbound delivery:** sidecar `lxmf_delivery.rs` / `lxmf_outbound.rs` / `pn_cascade.rs` (Direct-first; after Direct exhausts **multi-PN cascade**: preferred remote → other enabled remotes hop-sorted → in **Auto** only, up to 3 heard-but-not-added Discovered PNs hop-sorted → local-prop last; intermediate WS `sending` + `delivery_method: "propagated"` or `"stored_locally"`; terminal `delivered` at remote PN vs `stored_locally` for local inbox); renderer `applyReticulumOutboundDeliveryStatus.ts` (WS `lxmf_outbound_status` → Zustand + SQLite `delivery_status` + `delivery_method`; early-status buffer; hash/status allowlist), `reticulumOutboundFailureBridge.ts` (`shouldApplyLinkDeliveryTimeoutFailureBridge` skips the link-timeout Failed bridge when cascade capacity remains — remote **or** enabled local-prop; also skips `propagated` / `stored_locally` rows so cascade is not killed), `markStaleReticulumOutbound.ts`. Optimistic pending rows use `reticulum-pending-*`; send-path rekey passes `replaces_message_hash` on SQLite upsert to delete the prior pending hash. Remote PN Completes UI: **Stored at propagation node** (`ReticulumMessageStatusBadge` PN + green check); local-prop Completes: local inbox, not peer-delivered (PN + amber house). Mode Off has no cascade capacity, so the link-timeout bridge fails the row. **Paper exception:** `createReticulumPaperMessage` / paper create Completes immediately (`delivery_method: paper`, `ReticulumMessageStatusBadge` **Paper**) via `lxmf_message` — no `lxmf_outbound_status`; shared `reticulumMessageTransport` / `reticulumPaperErrors` keep IPC allowlists and i18n codes aligned. +- **LXMF outbound delivery:** sidecar `lxmf_delivery.rs` / `lxmf_outbound.rs` / `pn_cascade.rs` (Direct-first; after Direct exhausts **multi-PN cascade**: preferred remote → other enabled remotes hop-sorted → in **Auto** only, up to 3 heard-but-not-added Discovered PNs hop-sorted → local-prop last; intermediate WS `sending` + `delivery_method: "propagated"` or `"stored_locally"`; terminal `delivered` at remote PN vs `stored_locally` for local hosted PN). **Local-prop** is a full PN (in-process cascade deposit via `accept_stamped_propagated_blob`; host peer `/offer` sync; local Sync/retrieve via in-process `PropagationBridge::drain_local_inbox` — not remote `PropagationClient` `/get`) — not an outbox; clients need not Prefer you. Propagated **link establishment timeout** advances the cascade when other PNs remain (avoids Prefer-hash timeout storms). Sync vs deposit: `PROPAGATION_SYNC_OUTBOUND_BUSY` / `PN_DEPOSIT_DEFER_ADVANCE_AFTER`. Renderer `applyReticulumOutboundDeliveryStatus.ts` (WS `lxmf_outbound_status` → Zustand + SQLite `delivery_status` + `delivery_method`; early-status buffer; hash/status allowlist), `reticulumOutboundFailureBridge.ts` (`shouldApplyLinkDeliveryTimeoutFailureBridge` skips the link-timeout Failed bridge when cascade capacity remains — remote **or** enabled local-prop; also skips `propagated` / `stored_locally` rows so cascade is not killed), `markStaleReticulumOutbound.ts`. Optimistic pending rows use `reticulum-pending-*`; send-path rekey passes `replaces_message_hash` on SQLite upsert to delete the prior pending hash. Remote PN Completes UI: **Stored at propagation node** (`ReticulumMessageStatusBadge` PN + green check); local-prop Completes: deposited on your hosted node (PN + amber house; peer sync may still propagate). Mode Off has no cascade capacity, so the link-timeout bridge fails the row. **Paper exception:** `createReticulumPaperMessage` / paper create Completes immediately (`delivery_method: paper`, `ReticulumMessageStatusBadge` **Paper**) via `lxmf_message` — no `lxmf_outbound_status`; shared `reticulumMessageTransport` / `reticulumPaperErrors` keep IPC allowlists and i18n codes aligned. - **DM path reachability:** `useReticulumDmPathProbe.ts`, `reticulumDmPathReachability.ts`, `ReticulumDmPathReachabilityBadge.tsx` — Chat **Probe** matches Peer List (sidecar running check → `/probe` → toast → refresh); `applyProbeResult(forHash, …)` applies the settle without a second `/probe` and ignores stale completions after DM switch; manual reprobe forces Checking… even when passive hops look reachable; Peers virtualizes above 100 rows via `reticulumPeerListRows.ts`; peer refresh policy in `reticulumSidecarPeerRefreshEvents.ts` - **Inbound transport labels:** `received_via` resolves the path-table interface name against local interface config type, so a TCP hub display name still renders as TCP. - **Topology:** `via_hash` is an immediate transport id; sidecar synthesizes missing relay nodes. `ReticulumTopologyPanel` uses force layout; sidecar caps graph input at 2,000 peers and renderer caps visible peers at 800 (grid repulsion above 400). diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index 75ca35848..a830b1ddb 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -268,7 +268,7 @@ Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscri - **`rrc.disconnected`:** payload `{ hub_dest_hash, reason, will_reconnect? }`. When `will_reconnect` is `false` (or `reason` is `local_disconnect`), the renderer drops that hub session. When `true` (or omitted on older sidecars), the UI shows reconnecting and keeps volatile rooms until WELCOME. - **Outbound Direct backchannel:** On live stack start, `LinkDeliveryManager::set_inbound_packet_sender(spawn_lxmf_outbound_backchannel(...))` forwards plaintext on outbound-initiated reusable Direct links into the same unpack path as peer-initiated `lxmf.delivery`. Developer log marker: `LXMF outbound-link backchannel packet`. Without this wiring, the peer's first reply may Ack on their client but never appear in mesh-client Chat. -- **`lxmf_outbound_status`:** authoritative outbound delivery updates for **network** sends. Payload: `{ message_hash, status, delivery_method?, to_hash?, sent_via? }` where `status` is `delivered`, `stored_locally`, `failed`, or intermediate `sending` (egress upgrade or Direct→PN cascade step). mesh-client maps `delivered` / `stored_locally` → UI Completes (`acked`) and persists `delivery_status` (+ `delivery_method` when present) to SQLite; remote PN Completes (`delivered`) show **Stored at propagation node**; local-prop Completes (`stored_locally`) are local inbox only (not peer-delivered); `failed` → Failed. Do **not** treat `/api/v1/lxmf/send` response `delivery_status` (`queued`/`sending`) as terminal. After Direct exhausts, the sidecar **cascades** preferred remote → other enabled remotes (hop-sorted) → local-prop last, emitting `sending` + `delivery_method: "propagated"` or `"stored_locally"` between attempts before a final `delivered` / `stored_locally` / `failed`. **Paper create/ingest does not use this event** — Completes via `lxmf_message` with `delivery_method: "paper"` / `delivery_status: "delivered"`. +- **`lxmf_outbound_status`:** authoritative outbound delivery updates for **network** sends. Payload: `{ message_hash, status, delivery_method?, to_hash?, sent_via? }` where `status` is `delivered`, `stored_locally`, `failed`, or intermediate `sending` (egress upgrade or Direct→PN cascade step). mesh-client maps `delivered` / `stored_locally` → UI Completes (`acked`) and persists `delivery_status` (+ `delivery_method` when present) to SQLite; remote PN Completes (`delivered`) show **Stored at propagation node**; local-prop Completes (`stored_locally`) mean deposited on your hosted PN (full PN parity — peer `/offer` sync may still propagate; house badge ≠ dead-end outbox); `failed` → Failed. Do **not** treat `/api/v1/lxmf/send` response `delivery_status` (`queued`/`sending`) as terminal. After Direct exhausts, the sidecar **cascades** preferred remote → other enabled remotes (hop-sorted) → local-prop last, emitting `sending` + `delivery_method: "propagated"` or `"stored_locally"` between attempts before a final `delivered` / `stored_locally` / `failed`. **Paper create/ingest does not use this event** — Completes via `lxmf_message` with `delivery_method: "paper"` / `delivery_status: "delivered"`. - **`announce.received`:** coalesced WS notify for LXMF identity announces / path responses (named or nameless). Sidecar applies identity-key + display-name cache updates immediately, but emits **at most one** WS frame per coalesce window (500ms normal / 1000ms when >256 distinct destinations are pending) so announce storms stay O(1) bus pressure on large meshes (~100k). Payload is either a single `{ destination_hash, display_name?, hops, aspect?, identity_hash? }` (legacy / one-row flush) or `{ announces: [{ destination_hash, display_name?, hops, aspect?, identity_hash? }, ...] }` (capped at 1024, named preferred; overflow dropped — slow peer poll recovers). `aspect` is set when announce `name_hash` maps to a known app name (`lxmf.delivery`, `lxmf.propagation`, `nomadnetwork.node`, `rrc.hub`, `lxst.telephony`); omitted for path responses / unknown hashes (clients must not invent `"unknown"`). `identity_hash` is the hex identity recovered from the validated announce when present. Each flush publishes pressure counters under `GET /api/v1/diagnostics` → `announce_ws` (ingress/unique/overflow + storm/flush timestamps) for the Diagnostics `reticulum/announce-bus-pressure` warning. Display names update the peer-label cache only — announces do **not** auto-create LXMF contacts. That cache is overlayed onto `GET /api/v1/peers` / topology rows **and** onto nameless/hash-prefix rows from `GET /api/v1/contacts` (`list_contacts` may persist those fills) so path-table and contact refreshes keep announce aliases. - **`peers_updated`:** also emitted when the live path table **gains** new destination hashes (maintenance tick). Payload may include `{ added: string[], patches: PeerRow[], count }` (added/patches capped at 1024). Renderer applies patches incrementally, including route-field changes. A full peer dump is used on connect, manual Refresh, restart, safety poll, or a `peers_updated` payload that cannot be applied incrementally: `cleared`, `demoted_from_contacts`, or a single-`hash` probe/path event. Hop/timestamp-only churn does not emit. diff --git a/docs/reticulum.md b/docs/reticulum.md index d907ab483..4ef3bb2af 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -24,25 +24,25 @@ After changing interfaces on a live network, **restart the stack** so RNS picks ## What is included -| Area | Shipped behavior | -| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Stack lifecycle | Start / stop / auto-start; disconnect & quit. Sidecar **listen-first**: HTTP binds before live RNS/LXMF attach; Connect marks **configured** when HTTP + identity are ready (live attach / BLE may still be in progress) | -| Interfaces | TCP client, I2P (`peers`), Auto discovery, RNode (USB serial, `ble://…`, Wi‑Fi `tcp://host:7633`); default hub picker by region (Primary & Global selected by default; added disabled; syncs/repairs selected endpoints and disables remaining decommissioned testnet hubs) | -| Identity | Generate / import mnemonic; display name; encrypted export; **identity vault** passcode on Network tab | -| LXMF chat | DM-only text and reactions (outbound LXMF file/voice-note attach removed; attachment labels render; **cached raster images** display inline; use Remote rncp for peer files). **LXST live voice Call** is separate telephony (rsLXST), not an LXMF voice clip. | -| Remote | **rnsh** multi-session shell + **rncp** send/receive/fetch under one tab (Shell / Transfer / Saved / Settings); Chat DM send-file convenience; path-speed gate (TCP/network); inbound Ask/allow-list; auto-reconnect / auto-retry; LXMF “request enable receive” prompt between mesh-client peers | -| RRC | Reticulum Relay Chat — discovered/manual/favourite hubs, up to **8** concurrent sessions, hub/room auto-join, rooms, nicklists, slash commands (`/list`, `/who`, `/join`, …), @mention unread badges (also badges the **Reticulum protocol pill** with LXMF Chat), toasts when the RRC tab is inactive, automatic reconnect with backoff | -| Delivery | **Direct** when destination is in path table (outbound-initiated Direct replies need the sidecar **outbound Direct backchannel**). After Direct exhausts: **multi-PN cascade** — preferred remote → other enabled remotes (hop-sorted) → in **Auto** only, up to 3 heard-but-not-added **Discovered** PNs (hop-sorted, never persisted) → **local-prop last**. Remote PN Completes as `delivered` (**Stored at propagation node**, PN + green check); local-prop Completes as `stored_locally` (local inbox, not peer-delivered — PN + amber house). Propagation mode **Off** disables the cascade entirely. **Paper** for offline encrypted QR/`lxm://` handoff (no network — Completes immediately, no `lxmf_outbound_status`). Path/transport badges (RF/BLE/TCP/NET, multi, PN, Paper) are egress evidence — network UI stays **Sending** until `lxmf_outbound_status` (`delivered` / `stored_locally` / `failed`). Terminal `delivery_status` + `delivery_method` persist in SQLite. Local inbox Completes ≠ peer delivery. Inbound `received_via` / TCP badges use local interface **config type**, not display name. | -| Peers | RNS path table + messaged History + saved Contacts + Favorites (Peers tab sub-tabs); LXMFace avatars; probe; **LXST Call** and **LRGP Challenge** on rows; peer detail modal (Save as contact is manual) | -| Games | LRGP Tic-Tac-Toe + Chess via sibling [lrgp-rs](https://github.com/ratspeak/lrgp-rs); Games tab + Challenge from Peers/Chat; opponent labels via `resolveReticulumRemoteHashLabel`; deep-link `lrgp:` / `lxm://game/`; delivery chips + resend-after-restart (`games_outbound.db`); Chess promotion picker + threefold/50-move claims; wire-compatible with Ratspeak ([parity checklist](reticulum-games-parity.md)) | -| Topology | Best-effort graph from path-table next hops (not a full multi-hop trace) | -| Map | Local RMAP v4 discovery map (heard opt-in interfaces with GPS); link to rmap.world for global view | -| Nomad Network | Favourites / announces list (collapsible sidebar, default Favourites sub-tab) plus **My Pages** watched-folder hosting; **lazy-mount after first visit**; Micron (.mu) browser in a **dual-axis scroll shell**; **fit-width wrap default** with open-width toggle for ASCII pages; in-page navigation, back/forward, session page cache, `/file/` downloads, source toggle, and lxmf:// DM links; page/file errors humanized via `nomadPageErrorHumanize.ts`. Local hosting uses sibling [rsNomad](https://github.com/Colorado-Mesh/rsNomad) (`nomad-core`) for static `/page` + `/file` serving and `nomadnetwork.node` announces (no CGI). Choose a site root (`pages/`) or pages directory; FS watcher reloads routes; `nomad_serving_enabled` auto-restores after stack start. | -| Propagation | **Propagation mode** (Off / Auto / Manual; default **Off**) with per-mode guidance — Off disables sync **and** the outbound PN cascade, Auto one-time syncs and deposits on the best Discovered PN by hash without adding it or changing Preferred, Manual uses Preferred or picks the closest added node for that sync then falls back through the other added nodes to the local inbox. Preferred node, per-node **Sync messages**, rename/delete remote nodes, **Discovered on network** (Add / Add & prefer with `/offer` probe), optional **local PN hosting** (usable as the only PN), configurable **auto-sync interval**, Network **Advanced PN hosting** policy | -| Diagnostics | Reticulum-native interface / path / LXMF health and config audit (`reticulum/*` rows only on this tab; LoRa Hop Goblins and foreign-LoRa tables are Meshtastic/MeshCore-scoped) | -| Admin | RNode firmware flasher (Web Serial), stack factory reset | -| Sniffer / Stats | Reticulum packet log tab (`rawPacketLog.reticulum.*`) | -| Coexistence | BLE on a **different** MAC from Meshtastic/MeshCore; scan mutex; **Noble BLE yield** when an enabled BLE RNode is in config (sidecar suspends Noble on macOS/Windows so btleplug can pair) | +| Area | Shipped behavior | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Stack lifecycle | Start / stop / auto-start; disconnect & quit. Sidecar **listen-first**: HTTP binds before live RNS/LXMF attach; Connect marks **configured** when HTTP + identity are ready (live attach / BLE may still be in progress) | +| Interfaces | TCP client, I2P (`peers`), Auto discovery, RNode (USB serial, `ble://…`, Wi‑Fi `tcp://host:7633`); default hub picker by region (Primary & Global selected by default; added disabled; syncs/repairs selected endpoints and disables remaining decommissioned testnet hubs) | +| Identity | Generate / import mnemonic; display name; encrypted export; **identity vault** passcode on Network tab | +| LXMF chat | DM-only text and reactions (outbound LXMF file/voice-note attach removed; attachment labels render; **cached raster images** display inline; use Remote rncp for peer files). **LXST live voice Call** is separate telephony (rsLXST), not an LXMF voice clip. | +| Remote | **rnsh** multi-session shell + **rncp** send/receive/fetch under one tab (Shell / Transfer / Saved / Settings); Chat DM send-file convenience; path-speed gate (TCP/network); inbound Ask/allow-list; auto-reconnect / auto-retry; LXMF “request enable receive” prompt between mesh-client peers | +| RRC | Reticulum Relay Chat — discovered/manual/favourite hubs, up to **8** concurrent sessions, hub/room auto-join, rooms, nicklists, slash commands (`/list`, `/who`, `/join`, …), @mention unread badges (also badges the **Reticulum protocol pill** with LXMF Chat), toasts when the RRC tab is inactive, automatic reconnect with backoff | +| Delivery | **Direct** when destination is in path table (outbound-initiated Direct replies need the sidecar **outbound Direct backchannel**). After Direct exhausts: **multi-PN cascade** — preferred remote → other enabled remotes (hop-sorted) → in **Auto** only, up to 3 heard-but-not-added **Discovered** PNs (hop-sorted, never persisted) → **local-prop last**. Remote PN Completes as `delivered` (**Stored at propagation node**, PN + green check); local-prop Completes as `stored_locally` (deposited on your hosted PN — PN + amber house; full PN parity with `lxmd`, including peer `/offer` sync — not an outbox). Propagation mode **Off** disables the cascade entirely. **Paper** for offline encrypted QR/`lxm://` handoff (no network — Completes immediately, no `lxmf_outbound_status`). Path/transport badges (RF/BLE/TCP/NET, multi, PN, Paper) are egress evidence — network UI stays **Sending** until `lxmf_outbound_status` (`delivered` / `stored_locally` / `failed`). Terminal `delivery_status` + `delivery_method` persist in SQLite. Hosted-PN Completes ≠ recipient opened Chat. Inbound `received_via` / TCP badges use local interface **config type**, not display name. | +| Peers | RNS path table + messaged History + saved Contacts + Favorites (Peers tab sub-tabs); LXMFace avatars; probe; **LXST Call** and **LRGP Challenge** on rows; peer detail modal (Save as contact is manual) | +| Games | LRGP Tic-Tac-Toe + Chess via sibling [lrgp-rs](https://github.com/ratspeak/lrgp-rs); Games tab + Challenge from Peers/Chat; opponent labels via `resolveReticulumRemoteHashLabel`; deep-link `lrgp:` / `lxm://game/`; delivery chips + resend-after-restart (`games_outbound.db`); Chess promotion picker + threefold/50-move claims; wire-compatible with Ratspeak ([parity checklist](reticulum-games-parity.md)) | +| Topology | Best-effort graph from path-table next hops (not a full multi-hop trace) | +| Map | Local RMAP v4 discovery map (heard opt-in interfaces with GPS); link to rmap.world for global view | +| Nomad Network | Favourites / announces list (collapsible sidebar, default Favourites sub-tab) plus **My Pages** watched-folder hosting; **lazy-mount after first visit**; Micron (.mu) browser in a **dual-axis scroll shell**; **fit-width wrap default** with open-width toggle for ASCII pages; in-page navigation, back/forward, session page cache, `/file/` downloads, source toggle, and lxmf:// DM links; page/file errors humanized via `nomadPageErrorHumanize.ts`. Local hosting uses sibling [rsNomad](https://github.com/Colorado-Mesh/rsNomad) (`nomad-core`) for static `/page` + `/file` serving and `nomadnetwork.node` announces (no CGI). Choose a site root (`pages/`) or pages directory; FS watcher reloads routes; `nomad_serving_enabled` auto-restores after stack start. | +| Propagation | **Propagation mode** (Off / Auto / Manual; default **Off**) with per-mode guidance — Off disables sync **and** the outbound PN cascade. **Sync:** Auto one-time syncs the best Discovered PN by hash (does not add it or change Preferred), then configured remotes, then hosted local-prop; Manual syncs Preferred (or closest added for that sync) then other added remotes then hosted local-prop (`stored_locally` when that step Completes). **Outbound deposit cascade** (separate from Auto’s sync-target pick): preferred / configured remotes first → in Auto up to 3 Discovered PNs → local-prop last. Preferred node, per-node **Sync messages**, rename/delete remote nodes, **Discovered on network** (Add / Add & prefer with `/offer` probe), optional **local PN hosting** (usable as the only PN), configurable **auto-sync interval**, Network **Advanced PN hosting** policy | +| Diagnostics | Reticulum-native interface / path / LXMF health and config audit (`reticulum/*` rows only on this tab; LoRa Hop Goblins and foreign-LoRa tables are Meshtastic/MeshCore-scoped) | +| Admin | RNode firmware flasher (Web Serial), stack factory reset | +| Sniffer / Stats | Reticulum packet log tab (`rawPacketLog.reticulum.*`) | +| Coexistence | BLE on a **different** MAC from Meshtastic/MeshCore; scan mutex; **Noble BLE yield** when an enabled BLE RNode is in config (sidecar suspends Noble on macOS/Windows so btleplug can pair) | **Not in Reticulum mode:** Meshtastic/MeshCore-style RF channel chat, MQTT broker card, Meshtastic/MeshCore LoRa node position map, Rooms BBS, TAK, Meshtastic PKI Security tab, Hop Goblins routing diagnostics. (RRC is hub room chat over Reticulum Links — not LoRa RF channels.) @@ -285,10 +285,10 @@ When multiple enabled local RNode interfaces are connected, the interface list s - Text and emoji reactions. **Outbound LXMF file/voice attach is not offered** (removed); historic `[file:name:mime]` bubbles and inbound Sideband-style attachments render a read-only label; when the file remains in `reticulum/attachments/`, **raster images** (JPEG/PNG/GIF/WebP/AVIF/BMP — not SVG) display inline via main-process `chat:readReticulumAttachmentAsDataUrl` (magic-byte MIME check, 2 MiB cap, path jailed, IPC rate-limited). Peer file transfer is via Remote rncp. - **Replies:** outbound DMs stamp LXMF `FIELD_REPLY_TO` (0x30) and optional `FIELD_REPLY_QUOTE` (0x31, capped) before sign so peers see structured replies; ingest/Chat use `reticulum_reply_to_hash` plus quote preview (store parent when present, else wire quote) and jump-to-parent by message hash - **Reactions:** outbound tapbacks stamp standard LXMF `FIELD_REACTION` (0x40) before sign — a msgpack map (`REACTION_TO` = 32-byte parent hash, `REACTION_CONTENT` = emoji) — while still carrying the emoji as message content, so Ratspeak/Sideband render a structured reaction and clients that ignore `0x40` still show the emoji. Inbound decode is fail-open: a valid `0x40` maps to `reaction_target` (accepting a 32-byte binary or 64-hex `REACTION_TO`, normalized to lowercase) and wins over a reply for classification; a missing or malformed field leaves the reply/plain-text path unchanged. Ratspeak legacy `0xFB`/`0xFC` reaction envelopes are not emitted or decoded. -- Outbound **Sending** until sidecar emits `lxmf_outbound_status` (`delivered` / `stored_locally` / `failed`); `/api/v1/lxmf/send` may return `delivery_status: "queued"` or `"sending"` — that is enqueue/acceptance, not delivery confirmation. After Direct exhausts, the sidecar **cascades** preferred remote → other enabled remotes (hop-sorted) → in **Auto** only, up to 3 heard-but-not-added **Discovered** PNs (hop-sorted) → local-prop last, re-emitting `sending` with `delivery_method: "propagated"` (remote) or `"stored_locally"` (local inbox) between attempts. **Exception — paper:** Chat DM **Share as paper** (`createReticulumPaperMessage` → `POST /api/v1/lxmf/paper/create`) encrypts offline to a QR/`lxm://` URI with **no network send**; Completes immediately (`delivery_method: paper`, badge **Paper**) and does **not** use `lxmf_outbound_status`. Ingest via Chat **Scan paper**, Network **Scan / import**, or OS `lxm://` (`POST /api/v1/lxmf/paper/ingest` — HTTP `message` fallback-ingested when WS lags). Create needs peer pubkey (`identity_unknown` otherwise); ingest needs matching local identity (`decrypt_failed` otherwise); size-capped (`paper_too_large`). +- Outbound **Sending** until sidecar emits `lxmf_outbound_status` (`delivered` / `stored_locally` / `failed`); `/api/v1/lxmf/send` may return `delivery_status: "queued"` or `"sending"` — that is enqueue/acceptance, not delivery confirmation. After Direct exhausts, the sidecar **cascades** preferred remote → other enabled remotes (hop-sorted) → in **Auto** only, up to 3 heard-but-not-added **Discovered** PNs (hop-sorted) → local-prop last, re-emitting `sending` with `delivery_method: "propagated"` (remote) or `"stored_locally"` (hosted local PN) between attempts. **Exception — paper:** Chat DM **Share as paper** (`createReticulumPaperMessage` → `POST /api/v1/lxmf/paper/create`) encrypts offline to a QR/`lxm://` URI with **no network send**; Completes immediately (`delivery_method: paper`, badge **Paper**) and does **not** use `lxmf_outbound_status`. Ingest via Chat **Scan paper**, Network **Scan / import**, or OS `lxm://` (`POST /api/v1/lxmf/paper/ingest` — HTTP `message` fallback-ingested when WS lags). Create needs peer pubkey (`identity_unknown` otherwise); ingest needs matching local identity (`decrypt_failed` otherwise); size-capped (`paper_too_large`). - Terminal **Completes** / **Failed** from `lxmf_outbound_status` are persisted to SQLite (`delivery_status` + `delivery_method` on `reticulum_messages`) via `applyReticulumOutboundDeliveryStatus.ts` so restart/DB hydration keeps PN vs Direct vs local-inbox labeling; early WS events before provisional id→hash rekey are buffered - **Optimistic pending rekey:** Chat send creates a `reticulum-pending-*` row; when the sidecar returns the real `message_hash`, ingest/SQLite upsert passes `replaces_message_hash` so the pending row is deleted atomically (avoids orphan Sending duplicates) -- Remote PN Completes (`delivered`) render as **Stored at propagation node** — PN badge with a green check; local-prop Completes (`stored_locally`) render as **PN** with an amber **house** mark (`ReticulumMessageStatusBadge`) so the local propagation inbox is visually distinct from a peer-bound PN deposit — neither is recipient **Delivered** +- Remote PN Completes (`delivered`) render as **Stored at propagation node** — PN badge with a green check; local-prop Completes (`stored_locally`) render as **PN** with an amber **house** mark (`ReticulumMessageStatusBadge`) so a deposit on your hosted PN is visually distinct from a remote Prefer deposit — neither is recipient **Delivered**; local hosting still peers/propagates like `lxmd` - **DM path reachability:** active DM header shows a reachability badge (`ReticulumDmPathReachabilityBadge` + `useReticulumDmPathProbe`) seeded from path-table/contact hops, then settled by peer probe; when settled, **Request path** / **Probe** use the same sidecar endpoints as the Peers tab. Chat **Probe** mirrors Peer List UX: stack-running check → `/probe` → toast → peer refresh; `onProbeSettled` / `applyProbeResult(forHash, …)` applies the result without a second `/probe` (stale hashes after DM switch are ignored); manual reprobe forces Checking… even when passive hops already look reachable ## RRC (Reticulum Relay Chat) @@ -308,17 +308,17 @@ IRC-style multi-pane client (`RrcPanel` + `rrcHubStore` / `rrcSessionStore`): ### Delivery modes -| Path table | Propagation node | Routing / UI | -| ------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Destination present | — (no cascade candidates) | **Direct** link delivery; RF/BLE/TCP/NET (or explicit multi e.g. RF+TCP) badge = path-table / PacketTap egress — message stays **Sending** until `lxmf_outbound_status: delivered` | -| Destination present | Remotes and/or enabled local-prop | Direct-first; on Direct fail, **cascade** preferred remote → other enabled remotes (hop-sorted) → in **Auto** up to 3 Discovered PNs → local-prop last. Remote Completes → **PN** / **Stored at propagation node** (`delivered`); local-prop → **local inbox** (`stored_locally`) | -| Destination absent | Preferred / enabled remotes | **Propagated** via cascade (preferred first); **PN** badge — Completes as **Stored at propagation node** (not recipient-delivered) | -| Destination absent | Local-prop only | Completes as `stored_locally` in the **local propagation inbox** (not peer-delivered) | -| Destination absent | None | Error `no_propagation_node`; set a preferred **remote** node (or enable local-prop for inbox-only Completes) on Network tab | -| Any | Propagation mode **Off** | Direct only — no cascade candidates and no armed outbound PN; Direct exhaustion is terminal (**Failed**) and offline peers need Auto/Manual or **Paper** | -| n/a (offline) | n/a | **Paper** — encrypted QR/`lxm://` handoff (`DeliveryMethod::Paper`); no path table or PN; Completes immediately; badge **Paper**; does not use `lxmf_outbound_status` | - -**Path ≠ delivered:** a path-table entry means RNS knows a route, not that LXMF completed. Reticulum is async — offline peers need a **remote** propagation node (or **paper** QR handoff) for peer store-and-forward. **Local-prop** is last in the Direct→PN cascade and Completes as `stored_locally` (your inbox only — not peer delivery, badge **PN** + house). Remote PN Completes mean the PN accepted the encrypted blob (Ratspeak envelope parity), not that the recipient opened Chat. The renderer link-timeout Failed bridge skips while cascade capacity remains (any untried remote **or** enabled local-prop) — in mode **Off** there is no capacity, so the bridge fails the row. LXMF retrieval is **any-node**: deposit on PN A and Sync from PN B is valid when the fabric peers; parties need not share the same preferred PN. +| Path table | Propagation node | Routing / UI | +| ------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Destination present | — (no cascade candidates) | **Direct** link delivery; RF/BLE/TCP/NET (or explicit multi e.g. RF+TCP) badge = path-table / PacketTap egress — message stays **Sending** until `lxmf_outbound_status: delivered` | +| Destination present | Remotes and/or enabled local-prop | Direct-first; on Direct fail, **cascade** preferred remote → other enabled remotes (hop-sorted) → in **Auto** up to 3 Discovered PNs → local-prop last. Remote Completes → **PN** / **Stored at propagation node** (`delivered`); local-prop → **hosted PN** (`stored_locally`; peer sync may propagate) | +| Destination absent | Preferred / enabled remotes | **Propagated** via cascade (preferred first); **PN** badge — Completes as **Stored at propagation node** (not recipient-delivered) | +| Destination absent | Local-prop only | Completes as `stored_locally` on your **hosted PN** (full PN; clients need not Prefer you) | +| Destination absent | None | Error `no_propagation_node`; set a preferred **remote** node (or enable local-prop hosting) on Network tab | +| Any | Propagation mode **Off** | Direct only — no cascade candidates and no armed outbound PN; Direct exhaustion is terminal (**Failed**) and offline peers need Auto/Manual or **Paper** | +| n/a (offline) | n/a | **Paper** — encrypted QR/`lxm://` handoff (`DeliveryMethod::Paper`); no path table or PN; Completes immediately; badge **Paper**; does not use `lxmf_outbound_status` | + +**Path ≠ delivered:** a path-table entry means RNS knows a route, not that LXMF completed. Reticulum is async — offline peers need a propagation fabric (remote PN, **local hosting** with healthy peering, or **paper** QR handoff). **Local-prop** is a full Propagation Node (last in the sender cascade; Completes as `stored_locally`, badge **PN** + house) — not an outbox; fabric reachability depends on PN↔PN peering, and recipients need not Prefer you. Remote PN Completes mean the PN accepted the encrypted blob (Ratspeak envelope parity), not that the recipient opened Chat. Prefer link timeouts advance the cascade when other candidates remain. The renderer link-timeout Failed bridge skips while cascade capacity remains (any untried remote **or** enabled local-prop) — in mode **Off** there is no capacity, so the bridge fails the row. LXMF retrieval is **any-node**: deposit on PN A and Sync from PN B is valid when the fabric peers; parties need not share the same preferred PN. --- diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 24740970b..f87728a2e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1417,9 +1417,9 @@ Export for GitHub (`reticulum.sidecar.interfaceIssueAlert`, link-timeout counts) 1. Open **Network → Propagation** (Chat notice **Set up propagation** jumps there). 2. Add a **32-character LXMF destination hash** from whoever runs the propagation node you trust. 3. Pick a **Propagation mode** in the same section. Fresh installs default to **Off** (no automatic Preferred, no periodic sync). **Upgrades keep any saved mode** (including legacy **Auto**). Set **Preferred** manually and use **Manual** to sync that pin (or the closest added node when none is preferred), or use **Auto** to one-time sync the best **Discovered** node by hash (**without** adding it or changing Preferred), then configured remotes, then the local inbox. Set preferred / Add & prefer stay available in Auto. See [PN island / preferred mismatch](#reticulum-dm-shows-stored-at-propagation-node-but-the-reply-never-arrives-pn-island--preferred-mismatch) if both peers use different PNs. -4. **Local propagation hosting** stores messages for peers that sync with you and is **last** in the Direct→PN cascade (`stored_locally` — local inbox, not peer-delivered). Preferring Local shows a warning toast; it does **not** replace a remote PN for peer store-and-forward. +4. **Local propagation hosting** is a full LXMF Propagation Node (announce, admit deposits, peer `/offer` sync, client `/get`) — wire-compatible with official Python/`lxmd`. Clients **need not Prefer you**; Auto discovering your announce is enough. It is **last** in the sender’s Direct→PN cascade (`stored_locally` = deposited on your hosted node, amber house badge — not “outbox only”). Fabric delivery to peers who sync other PNs depends on **peering / PN↔PN propagation health**, not on recipients Preferring your local hash. Preferring Local still shows a warning toast (you become the Prefer pin for _your_ outbound cascade). -**Stale path + Failed via TCP:** When a path exists, mesh-client tries **Direct** first. If Direct fails, the sidecar **cascades** preferred remote → other enabled remotes (hop-sorted) → in **Auto** only, up to 3 heard-but-not-added **Discovered** PNs (hop-sorted) → local-prop last. Remote deposits Complete as `delivered` (**Stored at propagation node**); local-prop Completes as `stored_locally` (inbox only). The renderer link-timeout Failed bridge skips while cascade capacity remains. Without any cascade candidates, the row stays **Failed**. Check developer-bundle `reticulum/lxmf-outbound.log` for cascade lines. Persistent `proxyGet`/`proxyPost` storms may hit the shared **900/min** proxy ceiling (LXMF recent catch-up uses a dedicated **120/min** bucket; renderer backs off on rate-limit errors). +**Stale path + Failed via TCP:** When a path exists, mesh-client tries **Direct** first. If Direct fails, the sidecar **cascades** preferred remote → other enabled remotes (hop-sorted) → in **Auto** only, up to 3 heard-but-not-added **Discovered** PNs (hop-sorted) → local-prop last. Remote deposits Complete as `delivered` (**Stored at propagation node**); local-prop Completes as `stored_locally` (hosted on your PN; peer sync may still propagate). Prefer PN link timeouts **advance** the cascade when other candidates remain (they do not hammer the same Prefer hash until `syncTimedOut`). The renderer link-timeout Failed bridge skips while cascade capacity remains. Without any cascade candidates, the row stays **Failed**. Check developer-bundle `reticulum/lxmf-outbound.log` for cascade lines. Persistent `proxyGet`/`proxyPost` storms may hit the shared **900/min** proxy ceiling (LXMF recent catch-up uses a dedicated **120/min** bucket; renderer backs off on rate-limit errors). **Not the same as transport:** Ratspeak TCP hubs (e.g. `rns.ratspeak.org:4242`) and [rathole](https://github.com/ratspeak/rathole) are **connectivity / transport** tools, not LXMF propagation. mesh-client does not ship a default community propagation hash. diff --git a/org.coloradomesh.MeshClient.yml b/org.coloradomesh.MeshClient.yml index 1c210fc86..a1aa12467 100644 --- a/org.coloradomesh.MeshClient.yml +++ b/org.coloradomesh.MeshClient.yml @@ -92,15 +92,15 @@ modules: path: . - flatpak/generated-sources.json - type: archive - url: https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-linux-x64.tar.gz - sha256: b4ad6ad2b21db2f8cd50af416c3aa148ba704c31c84893f465a770a01c2c4572 + url: https://github.com/pnpm/pnpm/releases/download/v11.21.0/pnpm-linux-x64.tar.gz + sha256: aadc489ce4473c2af0fec06a5c19e113b5793d404eac49853c8153bf4b0d8263 dest: pnpm-vendor # pnpm tarball has root-level `pnpm` + `dist/`; default strip-components:1 drops the binary. strip-components: 0 only-arches: [x86_64] - type: archive - url: https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-linux-arm64.tar.gz - sha256: f00fc2041bb41742b7943bf2bb24183ad20320e8384824a8031eb94edf2f57a5 + url: https://github.com/pnpm/pnpm/releases/download/v11.21.0/pnpm-linux-arm64.tar.gz + sha256: 64eb219b008f7a4c176d81fbce919c20b0b7e093e815cbb669d46e681451f43b dest: pnpm-vendor strip-components: 0 only-arches: [aarch64] diff --git a/package.json b/package.json index de48f858a..93685d0cb 100644 --- a/package.json +++ b/package.json @@ -231,7 +231,7 @@ "vitest-axe": "1.0.0-pre.5", "zustand": "^5.0.14" }, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", + "packageManager": "pnpm@11.21.0+sha512.521705bce689924eac72f5a3587122f362689ef6571e55ba80076fd637c11132ecffada26fad4ea79c485bfddbfd3d5a2a5b05805a77e893de71ec8a6cca3bb1", "engines": { "node": ">=22.13.0", "pnpm": ">=11.0.0" diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 5dcb59ed0..1b4148ed5 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -3378,8 +3378,18 @@ impl LiveBridge { tracing::error!(target: "propagation-serve", "failed to start serve: {e}"); let mut router = self.router.lock().await; self.propagation.set_local_serving(false, &mut router); + if let Ok(mut driver) = self.outbound.lock() { + driver.set_local_prop_node(None); + } return; } + // Cascade Completes deposit into this store in-process (no self-Link). + // Pin the lxmf.propagation dest pubkey for any Link fallback path. + if let Ok(mut driver) = self.outbound.lock() { + let local_hex = self.propagation.local_dest_hash_hex(); + driver.pin_identity_for_propagation(&local_hex, self.identity.get_public_key()); + driver.set_local_prop_node(Some(self.propagation.local_node())); + } self.prop_announce.start( self.handle.transport_tx.clone(), self.identity.clone(), @@ -3390,6 +3400,9 @@ impl LiveBridge { } else { self.prop_announce.stop(); self.prop_serve.stop(); + if let Ok(mut driver) = self.outbound.lock() { + driver.set_local_prop_node(None); + } } } diff --git a/reticulum-sidecar/src/stack/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs index 2c7cc7181..220d4a88a 100644 --- a/reticulum-sidecar/src/stack/lxmf_outbound.rs +++ b/reticulum-sidecar/src/stack/lxmf_outbound.rs @@ -1,6 +1,7 @@ //! LXMF outbound delivery loop (Direct / Propagated) via LinkDeliveryManager. use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; use bytes::Bytes; use lxmf_core::constants::{ @@ -10,10 +11,12 @@ use lxmf_core::link_delivery::{ DeliveryResult, LinkDeliveryManager, is_retryable_link_delivery_failure, }; use lxmf_core::message::LxMessage; +use lxmf_core::propagation_node::PropagationNode; use lxmf_core::router::{ DirectDeliveryPlan, DirectDeliveryPlanInput, DirectReusableLinkState, DirectRouteSnapshot, LxmRouter, OutboundAction, plan_direct_delivery, }; +use lxmf_core::stamper; use rns_identity::identity::Identity; use rns_transport::messages::{TransportMessage, TransportQuery}; use tokio::sync::broadcast; @@ -127,6 +130,8 @@ const PN_DEPOSIT_DEFER_ADVANCE_AFTER: u32 = 8; /// Correlatable ids for an in-flight Propagated deposit (`pn_hash`, optional `transient_id`). type PendingPnDeposit = ([u8; 16], Option<[u8; 32]>); +/// Validated PN stamp entry: (transient_id, lxmf_data, stamp_u8, stamp_data). +type ValidatedPnStamp = ([u8; 32], Vec, u8, [u8; 32]); pub struct LxmfOutboundDriver { transport_tx: mpsc::Sender, @@ -165,6 +170,8 @@ pub struct LxmfOutboundDriver { /// Per-message PN target for the current cascade step (avoids retargeting the /// router-global `outbound_propagation_node` for concurrent sends). pending_pn_targets: HashMap<[u8; 32], [u8; 16]>, + /// When local-prop is serving, cascade deposits go in-process (no self-Link). + local_prop_node: Option>>, /// Local LXMF identity (retained for driver construction / future failed-detail payloads). #[allow(dead_code)] self_lxmf_hash: String, @@ -205,6 +212,7 @@ impl LxmfOutboundDriver { propagation_sync_target: None, pending_pn_deposits: HashMap::new(), pending_pn_targets: HashMap::new(), + local_prop_node: None, self_lxmf_hash: self_lxmf_hash.clone(), self_display_name, }; @@ -269,6 +277,14 @@ impl LxmfOutboundDriver { self.link_delivery.has_pending_to(dest) } + /// Wire (or clear) the in-process local PropagationNode for cascade Completes. + /// + /// When set, `pn_cascade_local` deposits call `accept_stamped_propagated_blob` directly + /// instead of opening a self-Link (official PN parity: host store, not loopback Link). + pub fn set_local_prop_node(&mut self, node: Option>>) { + self.local_prop_node = node; + } + pub fn known_identities_for_propagation(&self) -> HashMap { let mut out = self.known_identities.clone(); for (k, v) in &self.pinned_identities { @@ -462,10 +478,16 @@ impl LxmfOutboundDriver { prop_hash: [u8; 16], ) { let prop_hex = hex::encode(prop_hash); + let is_local_cascade = message + .hash + .or(message.message_id) + .is_some_and(|h| self.pn_cascade_local.contains(&h)); + // In-process local PN deposit does not need the PN Link — skip busy deferral. + let local_in_process = is_local_cascade && self.local_prop_node.is_some(); // Avoid racing a second LinkRequest to the same PN (sync or another deposit). let sync_blocks = self.propagation_sync_target == Some(prop_hash); let pending_blocks = self.link_delivery.has_pending_to(&prop_hash); - if should_defer_propagated_for_pn_link(sync_blocks, pending_blocks) { + if !local_in_process && should_defer_propagated_for_pn_link(sync_blocks, pending_blocks) { if let Some(msg_hash) = message.hash.or(message.message_id) { let defer_count = self .pn_deposit_defer_counts @@ -524,14 +546,12 @@ impl LxmfOutboundDriver { self.pn_deposit_defer_counts.remove(&hash); self.pending_pn_targets.insert(hash, prop_hash); } - // Local-prop cascade uses lxmf.propagation dest (not self LXMF). Identity should - // already be pinned via rehydrate; if missing, advance rather than path-hunt Nomad. - let is_local_cascade = message - .hash - .or(message.message_id) - .is_some_and(|h| self.pn_cascade_local.contains(&h)); + // Local-prop cascade uses lxmf.propagation dest (not self LXMF). In-process deposit + // does not need the PN pubkey in known_identities (no Link). Link path still requires it. if !self.known_identities.contains_key(&prop_hex.to_lowercase()) { - if is_local_cascade { + if local_in_process { + // Fall through to pack + accept_stamped_propagated_blob. + } else if is_local_cascade { tracing::warn!( target: "lxmf-outbound", prop = %prop_hex, @@ -548,28 +568,38 @@ impl LxmfOutboundDriver { return; } } + } else { + tracing::debug!( + prop = %prop_hex, + dest = %hex::encode(message.destination_hash), + "DeliverPropagated: PN identity unknown — requesting path" + ); + self.request_path_gated( + router, + event_tx, + prop_hash, + false, + "propagation node path", + message, + false, + ); + return; } - tracing::debug!( - prop = %prop_hex, - dest = %hex::encode(message.destination_hash), - "DeliverPropagated: PN identity unknown — requesting path" - ); - self.request_path_gated( - router, - event_tx, - prop_hash, - false, - "propagation node path", - message, - false, - ); - return; } - let Some(packed) = self.pack_for_propagation( - &mut message, - prop_hash, - router.get_stamp_cost(&prop_hash).unwrap_or(0), - ) else { + // Hosted PN admit floor is min_stamp_cost (stamp_cost − flex). Pack at least that + // when depositing in-process so accept_stamped_propagated_blob does not reject. + // try_lock: never block outbound tick on a contended PropagationNode mutex. + let target_cost = if local_in_process { + let local_floor = self + .local_prop_node + .as_ref() + .and_then(|n| n.try_lock().ok().map(|g| g.min_stamp_cost())) + .unwrap_or(0); + local_floor.max(router.get_stamp_cost(&prop_hash).unwrap_or(0)) + } else { + router.get_stamp_cost(&prop_hash).unwrap_or(0) + }; + let Some(packed) = self.pack_for_propagation(&mut message, prop_hash, target_cost) else { tracing::warn!( prop = %prop_hex, dest = %hex::encode(message.destination_hash), @@ -613,6 +643,18 @@ impl LxmfOutboundDriver { self.pending_pn_deposits .insert(hash, (prop_hash, message.transient_id)); } + // Local-prop cascade: deposit in-process (full hosted PN store) — avoid self-Link. + if is_local_cascade + && self.try_local_prop_in_process_deposit( + router, + event_tx, + &mut message, + prop_hash, + &packed, + ) + { + return; + } tracing::info!( target: "propagation-deposit", message_hash = message_hash_hex.as_deref().unwrap_or(""), @@ -637,13 +679,143 @@ impl LxmfOutboundDriver { error = %reason, "propagated link delivery start failed" ); + self.on_propagated_link_failure(router, event_tx, *err.message, prop_hash, &reason); + } + } + + /// Accept a packed propagation wrapper into the local hosted PN without LinkDelivery. + /// + /// Returns true when the deposit Completes (or failed terminal after accept miss). + fn try_local_prop_in_process_deposit( + &mut self, + router: &mut LxmRouter, + event_tx: &broadcast::Sender, + message: &mut LxMessage, + prop_hash: [u8; 16], + packed: &[u8], + ) -> bool { + let Some(node) = self.local_prop_node.clone() else { + return false; + }; + let prop_hex = hex::encode(prop_hash); + let Ok((_, entries)) = LxMessage::unpack_propagation_wrapper(packed) else { + tracing::warn!( + target: "propagation-deposit", + pn_hash = %prop_hex, + "local-prop in-process deposit: unpack wrapper failed — falling back to Link" + ); + return false; + }; + let Some(hash) = message.hash.or(message.message_id) else { + // Without a hash we cannot emit Completes — fall back to Link so + // requeue/status paths can run (do not accept into the store first). + tracing::warn!( + target: "propagation-deposit", + pn_hash = %prop_hex, + "local-prop in-process deposit missing message hash" + ); + return false; + }; + // Validate outside the node mutex (PoW/stamp work must not hold PropagationNode). + // min_cost 0: stamp already generated against the PN cost at pack time. + let mut validated: Vec = Vec::new(); + for entry in &entries { + let Some((transient_id, lxmf_data, stamp_value, stamp_data)) = + stamper::validate_pn_stamp(entry, 0) + else { + continue; + }; + let stamp_u8 = u8::try_from(stamp_value).unwrap_or(u8::MAX); + validated.push((transient_id, lxmf_data, stamp_u8, stamp_data)); + } + let mut accepted = 0usize; + let mut last_tid: Option<[u8; 32]> = None; + { + let Ok(mut guard) = node.try_lock() else { + return false; + }; + for (transient_id, lxmf_data, stamp_u8, stamp_data) in &validated { + if guard.accept_stamped_propagated_blob(lxmf_data, stamp_data, *stamp_u8) { + accepted += 1; + last_tid = Some(*transient_id); + tracing::info!( + target: "propagation-deposit", + pn_hash = %prop_hex, + transient_id = %hex::encode(transient_id), + stamp_value = stamp_u8, + blob_len = lxmf_data.len(), + "local PN accepted in-process cascade deposit" + ); + } + } + } + if accepted == 0 { + tracing::warn!( + target: "propagation-deposit", + pn_hash = %prop_hex, + entries = entries.len(), + "local-prop in-process deposit accepted zero entries — falling back to Link" + ); + return false; + } + self.pending_pn_deposits + .insert(hash, (prop_hash, last_tid.or(message.transient_id))); + self.handle_delivery_result( + router, + event_tx, + DeliveryResult::Complete { + link_id: prop_hash, + msg_hash: Some(hash), + }, + ); + true + } + + /// After a Propagated Link failure: advance cascade when other PNs remain; otherwise + /// requeue the same PN for path rediscovery while attempts remain. + fn on_propagated_link_failure( + &mut self, + router: &mut LxmRouter, + event_tx: &broadcast::Sender, + message: LxMessage, + prop_hash: [u8; 16], + reason: &str, + ) { + let msg_hash = message.hash.or(message.message_id); + if let Some(hash) = msg_hash { + self.mark_pn_tried(hash, prop_hash); + self.pending_pn_deposits.remove(&hash); + } + let ordered = self.ordered_pn_cascade(); + let tried = msg_hash + .and_then(|h| self.pn_cascade_tried.get(&h).cloned()) + .unwrap_or_default(); + // Prefer advancing to the next PN over hammering the same Prefer hash with + // link-establishment timeouts (observed with thunderhost / deadbeef). + if cascade_has_capacity(&ordered, &tried) { + tracing::info!( + target: "lxmf-outbound", + prop = %hex::encode(prop_hash), + reason, + tried = tried.len(), + candidates = ordered.len(), + "Propagated link failure — advancing PN cascade (other candidates remain)" + ); + match self.try_advance_pn_cascade(router, event_tx, message) { + Ok(()) => {} + Err(message) => self.emit_outbound_failed(router, event_tx, *message), + } + return; + } + if should_retry_propagated_link_failure(message.method, reason, message.delivery_attempts) { self.requeue_propagated_after_link_failure( - router, - event_tx, - *err.message, - prop_hash, - &reason, + router, event_tx, message, prop_hash, reason, ); + return; + } + match self.try_advance_pn_cascade(router, event_tx, message) { + Ok(()) => {} + Err(message) => self.emit_outbound_failed(router, event_tx, *message), } } @@ -1099,7 +1271,7 @@ impl LxmfOutboundDriver { "outbound PN deposit Completes" ); } - // Local-prop is offline inbox — not peer-delivered Complete. + // Local-prop Completes as hosted PN deposit (peer sync may still propagate). let status = if was_local { "stored_locally" } else { @@ -1152,17 +1324,6 @@ impl LxmfOutboundDriver { attempts = message.delivery_attempts, "LXMF delivery Failed" ); - // lxmd parity: Propagated "link closed"/timeout stay eligible for rediscovery. - if should_retry_propagated_link_failure( - message.method, - &reason, - message.delivery_attempts, - ) { - self.requeue_propagated_after_link_failure( - router, event_tx, message, dest_hash, &reason, - ); - return; - } // Exhaust alternate path slots / live ifaces before Direct→PN fallback. let message = if message.method == DeliveryMethod::Direct && is_retryable_link_delivery_failure(&reason) @@ -1176,11 +1337,10 @@ impl LxmfOutboundDriver { } else { message }; - // Propagated deposit failed after retries — mark this PN tried and advance. + // Propagated: advance cascade when other PNs remain; requeue only as last resort. if message.method == DeliveryMethod::Propagated { - if let Some(hash) = message.hash.or(message.message_id) { - self.mark_pn_tried(hash, dest_hash); - } + self.on_propagated_link_failure(router, event_tx, message, dest_hash, &reason); + return; } match self.try_advance_pn_cascade(router, event_tx, message) { Ok(()) => {} @@ -2247,4 +2407,437 @@ mod tests { "adapter must forward to LinkDeliveryManager" ); } + + #[test] + fn on_propagated_link_timeout_advances_cascade_when_other_pns_remain() { + // Observed Prefer hashes (thunderhost / deadbeef): link-timeout must not hammer + // the same PN while other cascade candidates remain. + use lxmf_core::constants::DeliveryMethod; + use lxmf_core::link_delivery::DeliveryResult; + use lxmf_core::message::LxMessage; + use lxmf_core::router::{LxmRouter, RouterConfig}; + use tokio::sync::broadcast; + + let identity = Identity::new(); + let (tx, _rx) = mpsc::channel(32); + let mut driver = LxmfOutboundDriver::new(tx, &identity, "aabb".repeat(8), "me".into()); + // Fixture hashes from Joey Prefer (9f3f…) / w0rmt Prefer (deadbeef). + let prefer = hex::decode("9f3f189e9f3f189e9f3f189e9f3f189e") + .ok() + .and_then(|b| <[u8; 16]>::try_from(b.as_slice()).ok()) + .unwrap_or([0x9f; 16]); + let next = hex::decode("deadbeefdeadbeefdeadbeefdeadbeef") + .ok() + .and_then(|b| <[u8; 16]>::try_from(b.as_slice()).ok()) + .unwrap_or([0xde; 16]); + let dest_hash = dest(0xcd); + let msg_hash = [0x42u8; 32]; + + let mut router = LxmRouter::new(RouterConfig::default()); + let (event_tx, mut event_rx) = broadcast::channel(16); + driver.set_propagation_node(&mut router, Some(prefer)); + driver.set_pn_cascade_candidates(vec![ + PnCascadeCandidate { + hash: prefer, + is_local: false, + is_discovered: false, + hops: Some(2), + id: "pn-9f3f189e".into(), + }, + PnCascadeCandidate { + hash: next, + is_local: false, + is_discovered: false, + hops: Some(3), + id: "pn-deadbeef".into(), + }, + ]); + + // Enter cascade on Prefer (marks Prefer tried). + let mut msg = LxMessage::new(dest_hash, [1u8; 16], "", "hi", DeliveryMethod::Direct); + msg.hash = Some(msg_hash); + assert!( + driver + .try_advance_pn_cascade(&mut router, &event_tx, msg) + .is_ok() + ); + assert_eq!(driver.pending_pn_targets.get(&msg_hash), Some(&prefer)); + + let mut failed = LxMessage::new(dest_hash, [1u8; 16], "", "hi", DeliveryMethod::Propagated); + failed.hash = Some(msg_hash); + failed.delivery_attempts = 1; + driver.handle_delivery_result( + &mut router, + &event_tx, + DeliveryResult::Failed { + link_id: prefer, + msg_hash: Some(msg_hash), + dest_hash: prefer, + message: failed, + reason: "link establishment timeout".into(), + }, + ); + + assert_eq!( + driver.pending_pn_targets.get(&msg_hash), + Some(&next), + "timeout on Prefer must advance to next PN (not requeue Prefer)" + ); + assert!(!driver.pn_cascade_local.contains(&msg_hash)); + + // Intermediate status stays sending/propagated (not terminal failed). + let mut saw_sending = false; + while let Ok(frame) = event_rx.try_recv() { + if frame.contains("\"status\":\"sending\"") && frame.contains("propagated") { + saw_sending = true; + } + assert!( + !frame.contains("\"status\":\"failed\""), + "must not emit failed while cascade capacity remains: {frame}" + ); + } + assert!(saw_sending, "cascade advance should emit sending"); + } + + #[test] + fn on_propagated_link_timeout_exhausts_to_failed_without_local_prop() { + use lxmf_core::constants::DeliveryMethod; + use lxmf_core::link_delivery::DeliveryResult; + use lxmf_core::message::LxMessage; + use lxmf_core::router::{LxmRouter, RouterConfig}; + use tokio::sync::broadcast; + + let identity = Identity::new(); + let (tx, _rx) = mpsc::channel(32); + let mut driver = LxmfOutboundDriver::new(tx, &identity, "aabb".repeat(8), "me".into()); + let prefer = [0x9fu8; 16]; + let dest_hash = dest(0xcd); + let msg_hash = [0x43u8; 32]; + + let mut router = LxmRouter::new(RouterConfig::default()); + let (event_tx, mut event_rx) = broadcast::channel(16); + driver.set_propagation_node(&mut router, Some(prefer)); + driver.set_pn_cascade_candidates(vec![PnCascadeCandidate { + hash: prefer, + is_local: false, + is_discovered: false, + hops: Some(2), + id: "pn-only".into(), + }]); + + let mut msg = LxMessage::new(dest_hash, [1u8; 16], "", "hi", DeliveryMethod::Direct); + msg.hash = Some(msg_hash); + assert!( + driver + .try_advance_pn_cascade(&mut router, &event_tx, msg) + .is_ok() + ); + + // Exhaust retry budget so last-candidate requeue is skipped → terminal failed. + let mut failed = LxMessage::new(dest_hash, [1u8; 16], "", "hi", DeliveryMethod::Propagated); + failed.hash = Some(msg_hash); + failed.delivery_attempts = MAX_DELIVERY_ATTEMPTS + 1; + driver.handle_delivery_result( + &mut router, + &event_tx, + DeliveryResult::Failed { + link_id: prefer, + msg_hash: Some(msg_hash), + dest_hash: prefer, + message: failed, + reason: "link establishment timeout".into(), + }, + ); + + let mut saw_failed = false; + while let Ok(frame) = event_rx.try_recv() { + if frame.contains("\"status\":\"failed\"") { + saw_failed = true; + } + } + assert!( + saw_failed, + "single Prefer with exhausted attempts and no local-prop → failed" + ); + } + + #[test] + fn deposit_defers_then_advances_when_sync_owns_pn_link() { + use lxmf_core::constants::DeliveryMethod; + use lxmf_core::message::LxMessage; + use lxmf_core::router::{LxmRouter, RouterConfig}; + use tokio::sync::broadcast; + + let identity = Identity::new(); + let (tx, _rx) = mpsc::channel(32); + let mut driver = LxmfOutboundDriver::new(tx, &identity, "aabb".repeat(8), "me".into()); + let prefer = [0x9fu8; 16]; + let next = [0xdeu8; 16]; + let dest_hash = dest(0xcd); + let msg_hash = [0x44u8; 32]; + let dest_pub = Identity::new().get_public_key(); + driver.register_identity_key(&hex::encode(dest_hash), dest_pub); + driver.register_identity_key(&hex::encode(prefer), Identity::new().get_public_key()); + driver.register_identity_key(&hex::encode(next), Identity::new().get_public_key()); + + let mut router = LxmRouter::new(RouterConfig::default()); + let (event_tx, _event_rx) = broadcast::channel(16); + driver.set_propagation_node(&mut router, Some(prefer)); + driver.set_pn_cascade_candidates(vec![ + PnCascadeCandidate { + hash: prefer, + is_local: false, + is_discovered: false, + hops: Some(1), + id: "pn-a".into(), + }, + PnCascadeCandidate { + hash: next, + is_local: false, + is_discovered: false, + hops: Some(2), + id: "pn-b".into(), + }, + ]); + driver.set_propagation_sync_target(Some(prefer)); + + let mut msg = LxMessage::new(dest_hash, [1u8; 16], "", "busy", DeliveryMethod::Propagated); + // Do not sign — this test only exercises sync-busy deferral (no pack). + msg.hash = Some(msg_hash); + msg.message_id = Some(msg_hash); + driver.mark_pn_tried(msg_hash, prefer); + driver.pending_pn_targets.insert(msg_hash, prefer); + + for _ in 0..PN_DEPOSIT_DEFER_ADVANCE_AFTER { + let attempt = msg.clone(); + driver.deliver_propagated(&mut router, &event_tx, attempt, prefer); + } + assert_eq!( + driver.pending_pn_targets.get(&msg_hash), + Some(&next), + "after PN_DEPOSIT_DEFER_ADVANCE_AFTER busy defers, cascade advances" + ); + } + + /// T1: outbound DeliverPropagated → in-process local accept → stored_locally → drain. + #[test] + fn local_prop_outbound_deposit_round_trip_stored_locally_then_drain() { + use lxmf_core::constants::DeliveryMethod; + use lxmf_core::message::LxMessage; + use lxmf_core::router::{LxmRouter, RouterConfig}; + use rns_identity::destination::Destination; + use tokio::sync::broadcast; + + let dir = std::env::temp_dir().join(format!( + "mesh-prop-outbound-rt-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("tmpdir"); + + let sender = Identity::new(); + let recipient = Identity::new(); + let local_prop = [0xabu8; 16]; + let zero_stamp_policy = crate::stack::pn_hosting_policy::PnHostingPolicy { + propagation_stamp_cost: 0, + propagation_stamp_flex: 0, + ..Default::default() + }; + let (tx, _rx) = mpsc::channel(32); + let bridge = crate::stack::propagation_bridge::PropagationBridge::new( + tx.clone(), + local_prop, + dir.clone(), + &recipient, + &zero_stamp_policy, + ) + .expect("bridge"); + + let sender_delivery = + Destination::hash_from_name_and_identity("lxmf.delivery", Some(&sender.hash)); + let recipient_delivery = + Destination::hash_from_name_and_identity("lxmf.delivery", Some(&recipient.hash)); + let mut driver = + LxmfOutboundDriver::new(tx, &sender, hex::encode(sender_delivery), "me".into()); + driver.register_identity_key(&hex::encode(recipient_delivery), recipient.get_public_key()); + driver.set_local_prop_node(Some(bridge.local_node())); + driver.set_pn_cascade_candidates(vec![PnCascadeCandidate { + hash: local_prop, + is_local: true, + is_discovered: false, + hops: Some(0), + id: "local-prop".into(), + }]); + + let mut router = LxmRouter::new(RouterConfig::default()); + let (event_tx, mut event_rx) = broadcast::channel(16); + + let mut msg = LxMessage::new( + recipient_delivery, + sender_delivery, + "", + "outbound local-prop round-trip", + DeliveryMethod::Direct, + ); + msg.sign(&sender.get_signing_key().expect("sk")) + .expect("sign"); + let msg_hash = msg.hash.expect("hash after sign"); + + assert!( + driver + .try_advance_pn_cascade(&mut router, &event_tx, msg) + .is_ok() + ); + assert!(driver.pn_cascade_local.contains(&msg_hash)); + + // Drive one outbound tick so DeliverPropagated → in-process accept. + driver.process_tick(&mut router, &event_tx); + + let mut saw_stored = false; + while let Ok(frame) = event_rx.try_recv() { + if frame.contains("\"status\":\"stored_locally\"") { + saw_stored = true; + } + } + assert!( + saw_stored, + "in-process local deposit must emit stored_locally" + ); + assert_eq!( + bridge.local_node().lock().expect("lock").message_count(), + 1, + "local PN store must hold the deposited blob" + ); + + let (messages, listed) = bridge.drain_local_inbox(); + assert_eq!(listed, 1); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].content, "outbound local-prop round-trip"); + assert_eq!(messages[0].method, DeliveryMethod::Propagated); + + let (again, listed_again) = bridge.drain_local_inbox(); + assert!(again.is_empty()); + assert_eq!(listed_again, 0); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// T5: deposit on local-prop appears in peer `/offer` inventory. + #[test] + fn local_prop_deposit_appears_in_peer_sync_offer_inventory() { + use lxmf_core::constants::DeliveryMethod; + use lxmf_core::message::LxMessage; + use lxmf_core::router::{LxmRouter, RouterConfig}; + use rns_identity::destination::Destination; + use tokio::sync::broadcast; + + let dir = std::env::temp_dir().join(format!( + "mesh-prop-peer-offer-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("tmpdir"); + + let sender = Identity::new(); + let recipient = Identity::new(); + let local_prop = [0xacu8; 16]; + let peer_pn = [0xbeu8; 16]; + let zero_stamp_policy = crate::stack::pn_hosting_policy::PnHostingPolicy { + propagation_stamp_cost: 0, + propagation_stamp_flex: 0, + ..Default::default() + }; + let (tx, _rx) = mpsc::channel(32); + let bridge = crate::stack::propagation_bridge::PropagationBridge::new( + tx.clone(), + local_prop, + dir.clone(), + &recipient, + &zero_stamp_policy, + ) + .expect("bridge"); + + let sender_delivery = + Destination::hash_from_name_and_identity("lxmf.delivery", Some(&sender.hash)); + let recipient_delivery = + Destination::hash_from_name_and_identity("lxmf.delivery", Some(&recipient.hash)); + let mut driver = + LxmfOutboundDriver::new(tx, &sender, hex::encode(sender_delivery), "me".into()); + driver.register_identity_key(&hex::encode(recipient_delivery), recipient.get_public_key()); + driver.set_local_prop_node(Some(bridge.local_node())); + driver.set_pn_cascade_candidates(vec![PnCascadeCandidate { + hash: local_prop, + is_local: true, + is_discovered: false, + hops: Some(0), + id: "local-prop".into(), + }]); + + let mut router = LxmRouter::new(RouterConfig::default()); + let (event_tx, _event_rx) = broadcast::channel(8); + let mut msg = LxMessage::new( + recipient_delivery, + sender_delivery, + "", + "pn-to-pn inventory", + DeliveryMethod::Direct, + ); + msg.sign(&sender.get_signing_key().expect("sk")) + .expect("sign"); + assert!( + driver + .try_advance_pn_cascade(&mut router, &event_tx, msg) + .is_ok() + ); + driver.process_tick(&mut router, &event_tx); + + let offer = { + let node_arc = bridge.local_node(); + let mut node = node_arc.lock().expect("lock"); + assert_eq!(node.message_count(), 1); + assert!(node.offer_generation() >= 1); + node.prepare_sync_offer(peer_pn) + }; + assert!( + !offer.transient_ids.is_empty(), + "host peer /offer must list deposited message for peered PN" + ); + + let live = include_str!("live.rs"); + assert!( + live.contains("local host queued outbound peer inventory sync"), + "production host peer loop must queue inventory sync" + ); + assert!( + live.contains("set_local_prop_node(Some"), + "serving must wire in-process local PN into outbound" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn pn_cascade_source_contract_includes_in_process_local_and_timeout_advance() { + let src = include_str!("lxmf_outbound.rs"); + assert!( + src.contains("try_local_prop_in_process_deposit"), + "local-prop cascade must deposit in-process (official PN parity)" + ); + assert!( + src.contains("on_propagated_link_failure"), + "Propagated link failures must advance cascade when capacity remains" + ); + assert!( + src.contains("advancing PN cascade (other candidates remain)"), + "timeout advance path must be logged for Prefer PN storms" + ); + } } diff --git a/reticulum-sidecar/src/stack/pn_cascade.rs b/reticulum-sidecar/src/stack/pn_cascade.rs index af367394a..07f0056f3 100644 --- a/reticulum-sidecar/src/stack/pn_cascade.rs +++ b/reticulum-sidecar/src/stack/pn_cascade.rs @@ -16,7 +16,7 @@ pub const MAX_AUTO_DISCOVERED_PN_CANDIDATES: usize = 3; #[derive(Debug, Clone, PartialEq, Eq)] pub struct PnCascadeCandidate { pub hash: [u8; 16], - /// True for local-prop / self LXMF hash (offline inbox — last resort only). + /// True for local-prop hosted PN (last in cascade; still a full PN for peer sync). pub is_local: bool, /// True for an ephemeral Auto candidate heard from an announce (never persisted). pub is_discovered: bool, @@ -28,7 +28,7 @@ pub struct PnCascadeCandidate { pub enum PnCascadePick { /// Deposit via a remote propagation node. Remote([u8; 16]), - /// Deposit into local-prop (offline inbox; not peer-delivered). + /// Deposit into local-prop (hosted PN Completes as `stored_locally`). Local([u8; 16]), /// No remaining candidates. Exhausted, diff --git a/reticulum-sidecar/src/stack/pn_hosting_apply.rs b/reticulum-sidecar/src/stack/pn_hosting_apply.rs index 3b911e0e9..54f53a3b4 100644 --- a/reticulum-sidecar/src/stack/pn_hosting_apply.rs +++ b/reticulum-sidecar/src/stack/pn_hosting_apply.rs @@ -102,4 +102,106 @@ mod tests { assert!(!router.peers.get(&discovered).is_some_and(|p| p.is_static)); assert_eq!(router.static_peers, vec![keep]); } + + /// T6: autopeer / static_peers / maxdepth / max_peering_cost from hosting policy. + #[test] + fn autopeer_respects_policy_cost_depth_and_static_peers() { + use lxmf_core::router::{AutopeerCandidate, RouterConfig}; + + let mut router = LxmRouter::new(RouterConfig::default()); + let static_peer = hash_from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let deep = hash_from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + let costly = hash_from_hex("cccccccccccccccccccccccccccccccc"); + let ok = hash_from_hex("dddddddddddddddddddddddddddddddd"); + + let policy = PnHostingPolicy { + autopeer: true, + autopeer_maxdepth: 2, + max_peering_cost: 20, + static_peers: vec![hex::encode(static_peer)], + ..Default::default() + }; + apply_pn_hosting_policy_to_router(&mut router, &policy); + assert!(router.static_peers.contains(&static_peer)); + + // Static peers peer even beyond autopeer_maxdepth. + assert!(router.autopeer(AutopeerCandidate { + destination_hash: static_peer, + timebase: 1.0, + transfer_limit: Some(256.0), + sync_limit: Some(1024.0), + stamp_cost: Some(16), + stamp_flexibility: Some(3), + peering_cost: Some(18), + metadata: None, + hops: Some(10), + })); + + // Discovered beyond maxdepth declined. + assert!(!router.autopeer(AutopeerCandidate { + destination_hash: deep, + timebase: 1.0, + transfer_limit: Some(256.0), + sync_limit: Some(1024.0), + stamp_cost: Some(16), + stamp_flexibility: Some(3), + peering_cost: Some(18), + metadata: None, + hops: Some(5), + })); + assert!(!router.peers.contains_key(&deep)); + + // Peering cost above max declined. + assert!(!router.autopeer(AutopeerCandidate { + destination_hash: costly, + timebase: 1.0, + transfer_limit: Some(256.0), + sync_limit: Some(1024.0), + stamp_cost: Some(16), + stamp_flexibility: Some(3), + peering_cost: Some(26), + metadata: None, + hops: Some(1), + })); + assert!(!router.peers.contains_key(&costly)); + + assert!(router.autopeer(AutopeerCandidate { + destination_hash: ok, + timebase: 1.0, + transfer_limit: Some(256.0), + sync_limit: Some(1024.0), + stamp_cost: Some(16), + stamp_flexibility: Some(3), + peering_cost: Some(18), + metadata: None, + hops: Some(1), + })); + assert!(router.peers.contains_key(&ok)); + } + + #[test] + fn autopeer_off_declines_discovered_candidates() { + use lxmf_core::router::{AutopeerCandidate, RouterConfig}; + + let mut router = LxmRouter::new(RouterConfig::default()); + apply_pn_hosting_policy_to_router( + &mut router, + &PnHostingPolicy { + autopeer: false, + ..Default::default() + }, + ); + let dest = hash_from_hex("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"); + assert!(!router.autopeer(AutopeerCandidate { + destination_hash: dest, + timebase: 1.0, + transfer_limit: Some(256.0), + sync_limit: Some(1024.0), + stamp_cost: Some(16), + stamp_flexibility: Some(3), + peering_cost: Some(18), + metadata: None, + hops: Some(1), + })); + } } diff --git a/reticulum-sidecar/src/stack/propagation_bridge.rs b/reticulum-sidecar/src/stack/propagation_bridge.rs index 4eaf8cf11..e3a811254 100644 --- a/reticulum-sidecar/src/stack/propagation_bridge.rs +++ b/reticulum-sidecar/src/stack/propagation_bridge.rs @@ -822,10 +822,9 @@ mod tests { use super::*; use lxmf_core::constants::DeliveryMethod; - /// local-prop loopback (plan acceptance gate): deposit stamped mail addressed - /// to our own `lxmf.delivery` hash into the local PN store, then confirm - /// `drain_local_inbox` runs the node's `/get` list → serve (stamp strip) → - /// purge, decrypts with the local identity, and yields the message once. + /// Manual blob → drain (partial loopback). Full outbound→stored_locally→drain + /// is covered by `local_prop_outbound_deposit_round_trip_stored_locally_then_drain` + /// in `lxmf_outbound.rs`. #[test] fn drain_local_inbox_delivers_then_purges_own_mail() { let dir = diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index ac06eb56e..399991390 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -4530,7 +4530,7 @@ "syncNow": "Sync messages", "syncNowFor": "Sync messages from {{name}}", "syncStarting": "Starting sync…", - "syncLocalSettled": "Synced with local inbox.", + "syncLocalSettled": "Synced with local propagation node.", "syncLocalSettledFor": "Synced with {{name}}.", "syncStatusWithTarget": "{{status}} ({{name}})", "syncErrorWithTarget": "{{name}}: {{message}}", @@ -4575,9 +4575,9 @@ "localHostStats": "{{count}} queued · {{bytes}} bytes", "localHostName": "Host propagation node", "localHostHint": "When enabled, this device announces as an LXMF propagation node and serves /offer and /get. Prefer a remote node for outbound store-and-forward to unreachable peers.", - "preferredLocalWarning": "Preferred node is Local only — offline peers still need a remote propagation node for store-and-forward delivery.", + "preferredLocalWarning": "Preferred node is your local propagation node — peers need not Prefer you; fabric delivery still depends on peering with other propagation nodes.", "enableLocalHostConfirmTitle": "Enable local propagation-node hosting?", - "enableLocalHostConfirmBody": "This device will announce as an LXMF propagation node and serve /offer and /get. That uses storage, CPU, and may peer with other nodes on the network. Prefer a remote node for outbound store-and-forward unless you intend to host.", + "enableLocalHostConfirmBody": "This device will announce as a full LXMF propagation node (admit deposits, /offer peer sync, /get retrieve) like official lxmd. That uses storage, CPU, and may peer with other nodes. Clients discovering your announce can Prefer/sync you; they do not have to Prefer you for the fabric to work when peering is healthy.", "enableLocalHostConfirm": "Enable hosting", "addProbing": "Checking /offer support…", "syncPeerCostExceedsMax": "This propagation node’s peering cost is above your max peering cost — raise the max in Network → Advanced PN hosting, or pick another node.", @@ -4633,8 +4633,8 @@ "modeManual": "Manual", "modeOff": "Off", "modeHelpOff": "Off: no propagation node support. Nothing syncs and offline messages are not deposited on any propagation node. A Preferred node stays saved but unused until you choose Auto or Manual.", - "modeHelpAuto": "Auto: one-time syncs the best Discovered propagation node (does not add it or change Preferred), then configured remotes, then the local inbox. With no network interfaces, settles local only.", - "modeHelpManual": "Manual: syncs your Preferred node, or picks the closest added node for that sync when none is preferred. If it fails, the other added nodes are tried, then the local inbox.", + "modeHelpAuto": "Auto: one-time syncs the best Discovered propagation node (does not add it or change Preferred), then configured remotes, then the local propagation node. With no network interfaces, settles local only.", + "modeHelpManual": "Manual: syncs your Preferred node, or picks the closest added node for that sync when none is preferred. If it fails, the other added nodes are tried, then the local propagation node.", "showChatNotice": "Show propagation reminder in Chat", "showChatNoticeAria": "Show the propagation node reminder banner in Chat", "showChatNoticeHint": "Turn this off to hide the Chat banner that appears when no propagation node is available."