A peer-to-peer networking tool with a web UI. Recon2x ships as a single self-contained executable — run the binary, it starts a local server and opens your browser. No installer, no Node, no dependencies.
The same binary runs in two roles:
- Default — desktop peer app. Embeds the SvelteKit UI; owns the local cryptographic identity and the UDP networking that connects to other peers.
--server— rendezvous server. A small HTTP service that lets peers find each other. Stores only opaque encrypted blobs; the operator (e.g. a Raspberry Pi athttps://rend.yourdomain.com) cannot read peer addresses.
┌─────────────────────────────┐
│ Rendezvous server (Pi) │
│ ├─ recon2x-backend --server │ HTTP only,
│ └─ cloudflared (tunnel out) │ zero-knowledge
└────────────────┬────────────┘
│ https://rend.<yourdomain>
┌──────────────────────────┴───────────────────────────┐
│ │
┌──────────────────┐ ┌──────────────────┐
│ Peer A │ │ Peer B │
│ ├─ SvelteKit UI │ │ ├─ SvelteKit UI │
│ ├─ /api/* │ │ ├─ /api/* │
│ └─ net/ (UDP) │ ◀──── direct UDP after punch ───▶│ └─ net/ (UDP) │
└──────────────────┘ (v6 direct, v4 punch, or └──────────────────┘
v6 parked-window — see below)
- IPv6 direct — both peers have v6; no NAT to punch through. Fastest.
- IPv4 hole punch — both peers have v4; deterministic schedule derived from server time, both sides fire HELLO bursts at the agreed instant.
- LAN / Bluetooth direct fallback — same-network peers are learned by a local peer-id beacon. Private LAN/Bluetooth addresses are not published to the rendezvous server.
- Parked v6 (no rendezvous round-trip) — when a friend is in "Parked" presence mode, both sides derive a periodic UDP port + open window from a shared seed. The seeker reaches the parked peer directly inside the window without re-querying the rendezvous server. See Presence modes.
Each peer advertises a presence mode inside its sealed announcement to each friend. Defaults to Manual; the user opts into the others.
| Mode | Receiver behavior | Use case |
|---|---|---|
| Manual | Inbound connect-requests prompt the user (Accept / Decline). | Safest default. |
| Auto | Paired-friend requests are accepted immediately by the UI, with a short undo window. | Two of your own devices, or a trusted friend you always want to reach. |
| Parked | Backend spawns a per-friend supervisor that wakes briefly each window, binds the derived v6 port, and listens. The friend's app reaches you directly with POST /api/connect/parked, no rendezvous round-trip. |
Mobile / "phone in pocket" — minimum cost when idle. |
Parked mode is v6-only by design — on v4 the NAT mapping breaks the "derive the port" assumption.
One binary, two halves:
- Frontend — SvelteKit built to static files (
adapter-static). Pure HTML/CSS/JS, no server runtime of its own. Multi-page: a login gate, a combined Chats surface (chat + rendezvous + pairing + peer list), a Media gallery, a Tester diagnostics page, and a full-screen Call route for live audio/video. - Backend — a Rust (
tokio+axum) service that embeds the frontend and serves it, exposes the local API, owns cryptographic identity, owns the UDP sockets, and orchestrates connections to other peers.
The frontend is compiled into the backend binary via rust-embed, so the
final executable needs no external files. The same Cargo build, with
--no-default-features, omits the embedded frontend — that's how the
rendezvous server is built on a host with no frontend assets (e.g. a Pi).
The UI is request/response and fits a web framework fine. NAT traversal and
UDP hole punching do not — they need a long-lived process holding open
sockets, tracking peer state, and sending keepalives. That logic lives in the
backend's net/ module and runs as persistent async tasks, independent of the
HTTP request lifecycle.
Every persona has its own 24-word BIP39 mnemonic. That mnemonic is stored on
disk as identity.enc — an argon2id-derived key + ChaCha20-Poly1305
ciphertext (see backend/src/identity/vault.rs).
On launch the app starts locked: /login asks for the passphrase, calls
POST /api/identity/unlock, and only then are the protected API routes
reachable (everything else returns 423 Locked).
While unlocked, the persona seed is also used to derive symmetric keys for:
- Chat bodies at rest — every saved chat message body is sealed with
ChaCha20-Poly1305 and prefixed
r2xchat:v1:(backend/src/chat_crypto.rs). - File attachments over sessions — attachment bytes are sealed with the
per-friend X25519 + XSalsa20-Poly1305 envelope and streamed in chunks,
with a
transfer_idso the UI can correlate progress events (backend/src/file_crypto.rs). - Audio / video call frames — every Opus/VP9 frame and mute event is
sealed under a per-call ChaCha20-Poly1305 subkey derived from the
friendship's X25519 DH secret + the call's
stream_id+ a fresh 8-byte salt the initiator picks. The seq rides in the nonce as anti-replay (backend/src/media_crypto.rs, backend/src/calls.rs).
Locking the app (POST /api/identity/lock or the sign-out button) drops all
unlocked seeds from memory and tears down parked supervisors.
Recon2x/
├── src/ SvelteKit frontend
│ ├── app.html, app.d.ts
│ ├── routes/
│ │ ├── +layout.svelte Topbar + boot/lock gate + nav + global
│ │ │ incoming-call listener / mini call widget
│ │ ├── +layout.js
│ │ ├── +page.svelte Redirects to /chats once unlocked
│ │ ├── login/+page.svelte Passphrase unlock + create-persona flow
│ │ ├── chats/+page.svelte The main surface: rendezvous join, discovery,
│ │ │ Pair Friend, friends/peer list, live chat
│ │ │ (sessions + history + attachments + edit/delete)
│ │ ├── media/+page.svelte Gallery of all attachments per persona
│ │ ├── tester/+page.svelte Diagnostics: connect traces + rendezvous log
│ │ └── call/[peer]/ Full-screen audio/video call surface
│ │ ├── +page.svelte Mic/camera capture, Opus/VP9 encode, tiles
│ │ └── +page.js
│ └── lib/
│ ├── api.ts Typed client for the local backend
│ ├── call.ts Browser call engine: WebCodecs/Opus capture,
│ │ WS audio/video up/downlink, jitter handling
│ ├── config.ts Dev-vs-bundled origin handling
│ ├── identity.svelte.ts Lock/unlock store
│ ├── app-state.svelte.ts Shared rendezvous/peers/friends state
│ ├── IncomingCallPreview.svelte Ringing banner for inbound calls
│ ├── MiniCallWidget.svelte Floating in-call widget shown off the call route
│ ├── MiniCallTile.svelte Small self/remote video tile
│ └── assets/favicon.svg
├── static/ Frontend static assets
├── build/ Frontend build output (gitignored)
├── backend/ Rust backend
│ └── src/
│ ├── main.rs Role dispatch: default = peer app, `--server` = rendezvous
│ ├── api/ Local HTTP/WS API + embedded-frontend serving
│ │ ├── mod.rs Router + shared AppState (Discovery, DB,
│ │ │ IdentityStore, Signals, Sessions, ParkedSupervisor,
│ │ │ RoomTracker, Diagnostics) + `require_unlocked` gate
│ │ ├── routes.rs Health, identity unlock/lock, personas, discovery,
│ │ │ rendezvous (join/poll/leave), diagnostics,
│ │ │ connect (request/accept/parked), pairing, friends,
│ │ │ sessions, self/friend chat history, streaming file
│ │ │ transfer, audio/video calls, media, WS
│ │ ├── rooms.rs RoomTracker — published presences; unpublish-on-shutdown
│ │ ├── diagnostics.rs In-memory trace + rendezvous log ring buffers
│ │ └── assets.rs Serves the embedded SvelteKit build (feature-gated)
│ ├── net/ Low-level networking
│ │ ├── socket.rs Dual-stack UDP socket helpers
│ │ ├── error.rs NetError
│ │ ├── stun.rs Minimal STUN client (RFC 5389)
│ │ ├── discovery.rs Public IPv4/IPv6 endpoint discovery
│ │ ├── lan.rs Local-only LAN/Bluetooth peer beacons
│ │ ├── listener.rs Multiplexed UDP receive loop per socket
│ │ ├── clock.rs NTP-style sync against the rendezvous
│ │ ├── punch.rs Scheduled IPv4 hole punching
│ │ ├── connect.rs Connect orchestrator (IPv6 / IPv4 / LAN / Bluetooth)
│ │ ├── event.rs Event-stream wire format: TAG_EVENT framing, all
│ │ │ KIND_* discriminators (chat, file, audio, video),
│ │ │ typed CBOR headers, derive_chat_stream_id
│ │ ├── parked.rs Parked-mode v6 schedule + listener + seeker
│ │ │ (shared-seed → time-bucketed port, HELLO/ACK)
│ │ └── parked_supervisor.rs Per-friend background task that wakes each
│ │ window, binds, listens, hands off to Sessions
│ ├── rendezvous/ Peer-discovery registry
│ │ ├── mod.rs In-memory `hash(word) → blobs` registry
│ │ ├── server.rs HTTP API (publish, unpublish, fetch, time, health)
│ │ ├── envelope.rs Tagged blob envelope (announce / sealed_announce /
│ │ │ connect_request / connect_accept /
│ │ │ pair_offer / pair_accept)
│ │ ├── client.rs Peer-side client (publish/fetch envelopes)
│ │ └── seal.rs Two-layer sealing: outer routing tag (HMAC,
│ │ rotates every 5 min) + inner crypto_box
│ │ (X25519 + XSalsa20-Poly1305, forward-secret
│ │ via per-publish ephemeral keys)
│ ├── identity/ Long-term cryptographic identity
│ │ ├── mod.rs Master seed (BIP39 mnemonic), load/save
│ │ ├── derive.rs HKDF per-friend X25519 key (keyed by friend's
│ │ │ master pubkey, no local indices) +
│ │ │ time-bounded burner sub-identities
│ │ ├── vault.rs Encrypted-at-rest mnemonic vault
│ │ │ (argon2id + ChaCha20-Poly1305)
│ │ ├── store.rs Multi-persona wallet (N independent mnemonics,
│ │ │ active selection, lock/unlock, legacy migration)
│ │ └── token.rs Signed capability tokens
│ ├── invite/ Token serialisation (text / Unicode QR / SVG QR)
│ ├── profile.rs Per-persona anonymous peer_id (JSON file)
│ ├── pairing.rs Word-code pairing handshake (4-word PSK, XSalsa20-
│ │ Poly1305 sealed identity cards)
│ ├── sessions.rs Live UDP session registry + keepalive; event-stream
│ │ dispatch (TAG_EVENT) with per-stream seq, dedupe,
│ │ ACK-after-persist, outbound-queue drain; chat
│ │ (text/edit/delete/clear/typing/read), streaming
│ │ file transfer, and the audio/video call pumps
│ ├── calls.rs Audio/video call state machine: Open/OpenAck/Close
│ │ lifecycle, video sub-stream, per-call key params
│ ├── chat_crypto.rs At-rest encryption for stored chat bodies
│ │ (incl. seal_bytes/open_bytes for binary blobs
│ │ used by the outbound_queue rows)
│ ├── file_crypto.rs Sealed file attachments + chunked transfer framing
│ ├── media_crypto.rs Per-call AEAD for audio/video frames (ChaCha20-
│ │ Poly1305 subkey from friendship DH + stream + salt)
│ ├── media_preview.rs ffmpeg-backed local preview transcoder
│ ├── media_vault.rs On-disk vault — outbound sealed file copies +
│ │ inbound resume state (partial + bitmap per stream)
│ ├── signaling/mod.rs In-memory inbound-request / pending-accept tracking
│ └── store.rs SQLite: themes, accounts, friends, chat messages,
│ attachments, outbound_queue (event-stream
│ store-and-forward) — scoped per persona
├── scripts/
│ ├── bundle.sh Build the bundled binary for the current OS
│ ├── release.sh Cut a tag + push, triggers CI for all 3 OSes
│ └── run-two-instances.sh Launch two local peer apps for end-to-end testing
├── docs/
│ ├── pi-setup.md Step-by-step Raspberry Pi rendezvous deploy
│ ├── chat-features-design.md Chat surface design notes
│ ├── event-stream-design.md Event-stream protocol migration design
│ │ (TAG_EVENT + per-kind, queue + drain — see below)
│ ├── streaming-file-transfer-design.md Staged streaming-upload + live-playback design
│ └── local-two-instance-testing.md How to run two peers on one machine
├── .github/workflows/
│ └── release.yml CI: builds Linux/macOS/Windows binaries on tag
└── dist/ Bundled executables (gitignored)
| Module | Status | Notes |
|---|---|---|
socket.rs |
✅ | Dual-stack UDP sockets. |
stun.rs |
✅ | Minimal STUN client. |
discovery.rs |
✅ | Discovers public IPv4 + IPv6 via STUN; multiple servers, configurable. |
listener.rs |
✅ | One receive loop per socket; demuxes STUN replies from peer packets by magic byte (0xCC). |
clock.rs |
✅ | NTP-style sync against the rendezvous server. |
punch.rs |
✅ | Scheduled IPv4 hole punching, deterministic schedule derived from server time. |
connect.rs |
✅ | Orchestrator: IPv6 direct → IPv4 punch → LAN direct → Bluetooth/PAN direct → error if no path. |
parked.rs |
✅ | v6-only parked-mode: shared-seed schedule (15 s windows every 2 min), listen_in_window binds the derived port, seek_in_window walks primary + 7 fallback ports. End-to-end v6-loopback handshake covered by a test. |
parked_supervisor.rs |
✅ | Per-friend background task; sleep-until-window → bind → listen → hand off to Sessions → loop. sync(desired) reconciles to a desired friend set on every poll. |
| Strict-NAT relay (TURN-style) | ❌ | Out of scope by design. Defeats the no-relay goal of the project. |
| Mixed-family bridging (v4-only ↔ v6-only) | ❌ | Cannot be done peer-to-peer — requires a relay box in the path for the entire session. Out of scope for the same reason. |
| Piece | Status |
|---|---|
| In-memory registry (Pi side) | ✅ Live at https://rend.vardhin.com |
| HTTP API: publish / unpublish / fetch / health / time | ✅ |
| Peer-side client (publish + fetch envelopes) | ✅ |
RoomTracker — backend remembers which rooms it published to, unpublishes them on shutdown |
✅ |
Tagged envelope shape (announce / connect_request / connect_accept / pair_offer / pair_accept) |
✅ |
Announcements carry anonymous peer_id + master_pubkey so peers can be deduped and paired friends recognised |
✅ |
| Zero-knowledge property |
| Piece | Status |
|---|---|
| Master seed (24-word BIP39) | ✅ Tested + loaded on startup |
| Encrypted-at-rest mnemonic vault (argon2id + ChaCha20-Poly1305) | ✅ |
Lock / unlock flow with 423 Locked middleware on protected routes |
✅ |
| Multi-persona wallet: independent mnemonics, profile, keypair, and friend set per persona | ✅ |
| Persona create / unlock / select / list API | ✅ |
| New-persona mnemonic returned once so the UI can show it for backup | ✅ |
| HD per-friend key derivation | ✅ Tested |
| Signed capability tokens (expiry, signature) | ✅ Tested |
| Invite serialisation (text + Unicode QR + SVG) | ✅ Tested |
| Master public key included in rendezvous announcements | ✅ |
| Friend-targeted sealed announcements to a specific friend's X25519 key | ✅ |
| Legacy plaintext-mnemonic layout auto-migrated to the encrypted vault on unlock | ✅ |
| Piece | Status |
|---|---|
| 4-word codes drawn from a curated ~256-word list (~32 bits of entropy) | ✅ Tested |
| Code normalisation (case- and whitespace-insensitive) | ✅ Tested |
Pairing room derived from SHA256("recon2x-pair/v1/room/" + code) |
✅ Tested |
| Symmetric seal key derived from the code via HKDF-SHA256 | ✅ |
Both peers exchange IdentityCard { master_pubkey, peer_id } sealed with XSalsa20-Poly1305 |
✅ |
Decrypted card persisted to local friends table (idempotent on re-pair) |
✅ |
| Recognition: paired friends show up with their label in any future rendezvous room | ✅ |
| PAKE upgrade (e.g. SPAKE2) to widen the offline-brute-force margin | ⏳ Optional follow-up |
| Piece | Status |
|---|---|
Connect button publishes a ConnectRequest envelope addressed to the target peer |
✅ |
| Frontend polls room every ~2.5s while joined; inbound requests surface as an Accept/Decline banner | ✅ |
| Manual / Auto / Parked presence mode sent on join and poll | ✅ |
| Auto mode: paired-friend requests are accepted immediately by the UI | ✅ |
| Auto-accept undo closes the just-opened live session | ✅ |
Accept publishes a ConnectAccept mirroring the request |
✅ |
Both sides then auto-run the existing connect() orchestrator (IPv6 direct → IPv4 punch → LAN/Bluetooth direct) |
✅ |
| Parked mode: paired friends can be reached through the scheduled v6 port with regular connect fallback | ✅ |
| Parked status endpoint exposes current/next window timing for countdowns and one-shot retry | ✅ |
| Per-peer stage trace exposed to the UI | ✅ |
| Trace + rendezvous activity recorded into the diagnostics ring buffers (Tester page) | ✅ |
| Piece | Status |
|---|---|
| Live session registry with keepalive | ✅ |
| Short chat messages (≤1900 bytes) over the session | ✅ |
Persistent per-friend chat history in SQLite, encrypted at rest with chat_crypto |
✅ |
Read receipts: per-message read_at_ms, peer notified when live |
✅ Now sent as KIND_MESSAGE_READ over the event-stream |
| File attachments sent in chunks over the session, with a stream id for UI progress | ✅ Now framed as event-stream Open + FileChunk + FileFin (file_crypto still seals the bytes) |
| Self-chat ("notes to yourself") with the same encrypted-at-rest storage | ✅ |
| WebSocket session events (open / close / heartbeat / message / read / typing / call lifecycle) + binary audio/video frames | ✅ |
| WebSocket client commands (typing, mutual/local delete, edit, set-auto-connect, mute, disconnect) | ✅ |
Event-stream dispatch — TAG_EVENT recv-loop arm with per-stream seq + dedupe + auto-ack |
✅ Step 3 of event-stream-design.md |
| Implicit chat stream — deterministic 16-byte stream id per pairing (HKDF-SHA256 over friend pubkey) | ✅ |
| Store-and-forward outbound queue — paired sends are queue-first; drains on session open; reverts in-flight rows on close | ✅ Step 4 |
ACK-after-persist (Tick 1) — receiver persists before sending EventAck; sender emits MessageDelivered when ack arrives |
✅ Step 4 |
Streaming file transfer — staged HTTP upload (begin/chunk/commit), sealed per-chunk, with pause/cancel and receiver-side range-streamable playback |
✅ See streaming-file-transfer-design.md |
Receiver-resume for files — <stream>.partial + <stream>.bitmap on disk; reconnect → immediate EventNack of just the missing chunks |
✅ (improvisation over the design doc) |
| Per-friend auto-connect toggle + just-in-time send for auto-off friends | ✅ |
| Typing / presence events (live, no buffer) | ✅ KIND_TYPING_START/STOP, KIND_PRESENCE |
| Mutual + local message delete, message edit, full chat clear | ✅ KIND_MESSAGE_DELETE, KIND_MESSAGE_EDIT, KIND_CHAT_CLEAR |
| Audio calls — Opus frames over the session, per-call sealed, mic mute, accept/reject/hangup, ringing UI | ✅ See calls.rs |
| Video sub-stream — VP9 over a second stream on top of the audio call; keyframe flag, camera mute, loss feedback → encoder bitrate stepping | ✅ |
| Piece | Status |
|---|---|
Deduplicates inbound ConnectRequest envelopes by nonce |
✅ |
Tracks pending ConnectAccept envelopes for outbound requests |
✅ |
| TTL-prunes stale entries | ✅ |
| Live WebSocket punch signalling (replace rendezvous polling for requests/accepts) | ⏳ Future |
| Piece | Status |
|---|---|
Audio call lifecycle (Open/OpenAck/Close) over the event-stream |
✅ |
Per-call sealed Opus frames (KIND_AUDIO_FRAME), mic mute (KIND_AUDIO_MUTE) |
✅ |
Accept / reject / hangup, ringing offer surfaced over WS (CallOffered/CallAccepted/CallRejected/CallEnded) |
✅ |
| Video sub-stream on a second stream_id with its own key + seq space | ✅ |
Sealed VP9 frames (KIND_VIDEO_FRAME, keyframe flag), camera mute (KIND_VIDEO_MUTE) |
✅ |
Receiver loss feedback (KIND_VIDEO_FEEDBACK) → sender steps encoder bitrate |
✅ |
| Per-call ChaCha20-Poly1305 subkey from friendship X25519 DH + stream_id + initiator salt; seq-in-nonce anti-replay | ✅ |
| Browser-side capture/encode is the source of truth; backend never touches plaintext media bytes | ✅ |
| Piece | Status |
|---|---|
themes + accounts tables (UI preferences) |
✅ |
friends table (persona id, label, master pubkey, last-seen peer id, friend X25519 pubkey, auto_connect) |
✅ |
Idempotent upsert_friend keyed by persona + master pubkey; friend rename + delete; per-friend auto_connect toggle |
✅ |
chat_messages table with encrypted bodies, attachments, read state, per-message delete + edit |
✅ |
| Legacy global friend uniqueness migrated to persona-scoped uniqueness | ✅ |
outbound_queue table — store-and-forward for paired chat / read-receipts / file-Open. FIFO drain by enqueued_at, restart-safe per-stream seq via MAX(seq)+1. Header + payload encrypted at rest via chat_crypto::seal_bytes. |
✅ Step 4 |
data_dir() made pub so media_vault can place files in the same root |
✅ Step 5 |
| Surface | Status |
|---|---|
| Login — passphrase unlock, create new persona, switch persona (with one-time mnemonic on create) | ✅ |
Topbar / layout gate — redirects locked → /login, unlocked → /chats; persona menu + sign-out; global incoming-call banner + floating mini-call widget across all routes |
✅ |
| Chats — connect/discovery — discovery + IPv4/IPv6 endpoints, rendezvous URL/word/Join, Manual/Auto/Parked selector, friends-first peer list with debug "show unpaired peers", Pair Friend (generate/enter code), friends list with remove/rename | ✅ Folded into the Chats surface (the old separate Setup page is gone) |
Chats — persistence — mode and inputs persisted per rendezvous URL+word in localStorage |
✅ |
| Chats — peer rows — show verified/plaintext + mode badges, deduped by identity, named with local labels | ✅ |
| Chats — Auto mode — auto-accept toast with undo for paired-friend requests | ✅ |
| Chats — Parked — "Reach now" with open/next-window countdown, auto-retry once open, rendezvous fallback | ✅ |
| Chats — messaging — live UDP chat panel + persistent history, streaming attachment send/receive with progress + pause/cancel + inline playback, read receipts, typing indicators, message edit + delete (local/mutual), self-chat | ✅ |
Call (/call/[peer]) — full-screen audio/video: mic + camera capture, Opus/VP9 encode in the browser, mute toggles, self + remote tiles, hangup |
✅ |
| Media — gallery of every attachment across friends for the active persona, decrypted on demand | ✅ |
| Tester — connect trace log, rendezvous activity log, currently-joined rooms | ✅ |
The protocol redesign described in
docs/event-stream-design.md is now essentially
complete. The old single-feature tags (TAG_MSG, TAG_MSG_READ,
TAG_FILE_CHUNK, …) have been collapsed into one TAG_EVENT framing with a
per-stream sequence space and a kind discriminator, so chat, files, typing,
read receipts, and audio/video all ride the same wire format — adding a new
feature is a new KIND_*, not a new tag + ad-hoc framing.
Wire format (after the 0xCC magic byte):
+--------+------------------+--------+--------+----------+----------+----------+
| 0x40 | stream_id (16) | seq(4) | kind(1)| flags(1) | hdr_len | header |
| EVENT | | BE u32| | | (2, BE) | (hdr_len)|
+--------+------------------+--------+--------+----------+----------+----------+
| payload (rest of UDP packet) |
+------------------------------------------------------------------------------+
Typed headers and per-kind metadata are CBOR-encoded (ciborium). Flags are
EOS | KEYFRAME | WANTS_ACK (bitflags).
The 12 steps below come from §17 of the design doc. They're executed one at a time so each commit is a working checkpoint.
| # | Step | Status |
|---|---|---|
| 1 | net/event.rs — wire format + decode/encode + unit tests |
✅ |
| 2 | SessionEvent enum expansion (additive — old fields kept) |
✅ |
| 3 | Session recv-loop dispatches TAG_EVENT; chat TextMessage + MessageRead ported |
✅ |
| 4 | outbound_queue table + drain procedure + ACK-after-persist (Tick 1) |
✅ |
| 5 | File transfer ported onto streams + Open metadata + queued-on-offline + receiver-resume |
✅ |
| 6 | Per-friend auto_connect column + toggle wiring |
✅ |
| 7 | Just-in-time send for auto-off friends | ✅ |
| 8 | MessageDelete / MessageEdit / ChatClear events + local commands |
✅ |
| 9 | Typing / presence events (live, no buffer) | ✅ |
| 10 | IPv4 parked-mode via timed rendezvous attendance | ⏳ Still open |
| 11 | Delete old TAG_MSG / TAG_FILE_* tags + builder/parser functions |
✅ |
| 12 | Audio / video calls (Opus + VP9, full lifecycle — went well past the original "stub" plan) | ✅ See calls.rs |
- backend/src/net/event.rs — pure wire format. Tag, all
KIND_*discriminators (chat / file / audio / video),EventFlags,EventFrameencode/decode, typed CBOR headers (OpenHeader,FileMeta,TextMessageHeader,MessageReadHeader,MessageEditHeader, mute/feedback headers,EventAckHeader,EventNackHeader, …),derive_chat_stream_id(HKDF-SHA256 over friend pubkey or peer_id). - backend/src/sessions.rs — recv-loop dispatch on
TAG_EVENT, per-stream inbound dedupe + outbound seq, the queue drain pump, file-stream registries, chat/typing/edit/delete/clear handling, and the audio/video call pumps. - backend/src/calls.rs — the audio/video call state machine and the
per-call
Openmetadata (AudioCallMeta/VideoCallMeta) carrying the key salt. - backend/src/media_crypto.rs — per-call AEAD: the
CallKey/CallParamsderivation and per-frame seal/open with seq-in-nonce anti-replay. - backend/src/store.rs —
outbound_queuetable (encrypted-at-rest viachat_crypto::seal_bytes), the drain/seq helpers, and the per-friendauto_connectcolumn. - backend/src/media_vault.rs — on-disk vault for outbound sealed files
- inbound resume state (
partial+bitmapper stream).
- inbound resume state (
- backend/src/chat_crypto.rs —
seal_bytes/open_bytesfor binary blobs used byoutbound_queuerows.
A few choices diverge from the design doc as written; each is called
out in the doc's > Improvisations callout and recapped here:
-
Receiver-resume for files. The doc only describes whole-file retransmit after FIN + NACK rounds. We added a per-stream
<stream>.partial(sparse-allocated toencrypted_size) and a<stream>.bitmap(one byte per chunk, 0 = missing, 1 = received) on the receiver side, both undervault/inbound/.... On reconnect, a re-sentOpenfor an existing partial triggers an immediateEventNacklisting just the missing chunks — the sender resends only what's actually needed. Tested in media_vault::tests::inbound_partial_resume. -
Sender vault for outbound files. §12.3 of the design doc assumes the queued
Openrow stores apayload_refpointing at the user's original file, so we don't duplicate bytes. Our HTTP upload path doesn't give us a path to keep — the user POSTs bytes via the browser. So we seal the bytes once (file_crypto::seal_file) and write the ciphertext under<data_dir>/vault/outbound/<persona>/<friend>/<stream>.bin. The user can delete the original immediately — we already hold an encrypted-at-rest copy that survives until the receiver signals completion viaClose { reason: "complete" }. A planned vault page in the UI will surface these files alongside received attachments. -
Auto-accept Open. The doc's UX (§7.1, §16) prompts the user via a
StreamOfferedWS event and waits for anaccept_streamcommand. We emitStreamOfferedso the frontend can show the prompt later, but for now the backend auto-acks everyOpen— matching today's behaviour (files just appear) and avoiding a silent hang while step 7's UI is still pending. -
Legacy tags removed (step 11 done). The old per-feature
TAG_MSG/TAG_MSG_READ/TAG_FILE_*arms have been deleted; the recv loop now only understandsTAG_EVENT. Because both ends ship together (no migration path), this is a clean break rather than a compatibility window. -
Restart-safe per-stream seq. §12.4 of the doc has
seq INTEGER NOT NULL -- assigned at enqueue, fixes order. We assign each new row'sseqviaMAX(seq) + 1over the queue rows for thatstream_id, so the sequence space is preserved across app restarts even after rows have been acked-and-deleted. The chat stream id is itself stable across restarts (HKDF over the friend's master pubkey), so the sequence space is durably contiguous per pairing.
- No proactive retransmit timer. §6.3 mentions a
RETRANSMIT_TIMEOUTper event; today reliability is "send once per drain pass; if no ack, the row reverts to pending on session close and the next session retries." Works for the in-session retry that matters most for chat; file NACK rounds (§5.3) still apply on top. - No proactive mid-stream gap detection. Receiver only NACKs on
FileFinor on resume — not during the chunk stream itself. §11 q5 flagged this as a later optimisation. - Drain ordering is strict FIFO across streams. §18 says priority
(chat > file > …) is the right long-term answer; we ship simple FIFO
by
enqueued_atfirst.
Protected routes return 423 Locked when no persona is unlocked. The
public routes below are exempt.
Public (no unlock required):
| Route | Method | Purpose |
|---|---|---|
/ |
GET | The embedded SvelteKit UI. |
/api/health |
GET | Liveness — { status, version }. |
/api/identity/state |
GET | { locked, active_slug, personas }. |
/api/identity/unlock |
POST | { slug, passphrase, set_active? } — decrypt vault, optionally activate. |
/api/identity/lock |
POST | Drop all unlocked seeds + stop parked supervisors. |
/api/personas |
GET | List personas — [{ slug, label, master_pubkey, peer_id, is_active }]. |
/api/personas |
POST | { slug, label, passphrase } — create a new independent persona; response includes mnemonic once. |
/api/personas/select |
POST | { slug } — switch active persona (must already be unlocked); stops previous persona's parked supervisors. |
Protected (require an unlocked persona):
| Route | Method | Purpose |
|---|---|---|
/api/discovery |
GET | Discover both public endpoints (IPv4 + IPv6) via STUN. |
/api/discovery/ipv4 |
GET | Re-query just the IPv4 endpoint. |
/api/discovery/ipv6 |
GET | Re-query just the IPv6 endpoint. |
/api/rendezvous/check |
POST | { url } — verify a rendezvous server is reachable. |
/api/rendezvous/join |
POST | { url, word, mode?, show_strangers? } — publish own announcement, return deduplicated peers (keyed by master pubkey or peer_id). |
/api/rendezvous/poll |
POST | { url, word, mode?, show_strangers? } — re-publish presence, return peers + inbound connect-requests addressed to us. Pending requests include auto_accept. |
/api/rendezvous/leave |
POST | { url, word } — explicit /r/unpublish for every owner tag we used in that room, then stop parked supervisors. |
/api/diagnostics/traces |
GET | Newest-first connect-attempt log (Tester page). |
/api/diagnostics/rendezvous |
GET | Newest-first rendezvous publish/poll/fetch/unpublish log. |
/api/diagnostics/rooms |
GET | Currently tracked room subscriptions. |
/api/connect |
POST | { ipv4, ipv6, rendezvous_url } — direct connect (no handshake). Returns a structured trace of stages. |
/api/connect/request |
POST | { rendezvous_url, word, to_peer_id, to_ipv4?, to_ipv6? } — publish a ConnectRequest, wait up to ~30s for the accept, then run the connect orchestrator. |
/api/connect/accept |
POST | { rendezvous_url, word, nonce, from_peer_id, from_ipv4?, from_ipv6?, punch_at_ms } — mirror an inbound request and run the connect orchestrator. |
/api/connect/parked |
POST | { master_pubkey, peer_v6_ip } — reach a parked friend inside their current v6 window. |
/api/connect/parked/status |
GET | Current/next parked-window timing for friends with cached X25519 keys. |
/api/media/preview |
POST | { name, mime?, data_base64 } — shell out to ffmpeg to render a browser-friendly PNG/WAV/MP4 preview of a small local attachment. |
/api/media |
GET | Every file attachment across every friend for the active persona, newest first. |
/api/self/messages |
GET / POST | Fetch or save encrypted notes to the active persona's own chat. |
/api/self/messages/:msg_id/read |
POST | Mark a self-chat message as read locally. |
/api/self/files |
POST | Save an encrypted file attachment to yourself (name, mime?, data_base64). |
/api/friends/:pubkey/messages |
GET | Persistent chat history for a friend (works even with no live session). |
/api/sessions |
GET | List live UDP sessions. |
/api/sessions/:peer_id/messages |
GET / POST | Fetch or send small chat messages (≤1900 bytes) over an established session. |
/api/sessions/:peer_id/messages/:msg_id/read |
POST | Mark a received message as read; notifies the peer via KIND_MESSAGE_READ if live. |
/api/sessions/:peer_id/files |
POST | Send a small encrypted file attachment in one shot (name, mime?, data_base64). |
/api/sessions/:peer_id/files/begin |
POST | Begin a streaming file transfer; returns a transfer_id. |
/api/sessions/:peer_id/files/:transfer_id/chunk?idx=N |
POST | Upload one plaintext chunk (raw binary body); backend seals and sends it. |
/api/sessions/:peer_id/files/:transfer_id/commit |
POST | Signal the last chunk is in; finishes the transfer. |
/api/sessions/:peer_id/files/:transfer_id/pause |
POST | { paused } — pause/resume an outgoing transfer. |
/api/sessions/:peer_id/files/:transfer_id/cancel |
POST | Cancel an in-progress transfer (notifies the peer). |
/api/files/inbound/:transfer_id/stream?mime=<type> |
GET | Range-streamable bytes of an inbound transfer for live <video>/<audio> playback as chunks arrive. |
/api/sessions/:peer_id/calls/start |
POST | { with_video } — start an outgoing audio call; returns the call's stream_id. |
/api/sessions/:peer_id/calls/accept |
POST | { stream_id } — accept an incoming call. |
/api/sessions/:peer_id/calls/reject |
POST | { stream_id, reason? } — reject an incoming call. |
/api/sessions/:peer_id/calls/hangup |
POST | End the active call (tears down any video sub-stream). |
/api/sessions/:peer_id/calls/mute |
POST | { muted } — toggle local mic mute. |
/api/sessions/:peer_id/calls/video/start |
POST | Open a video sub-stream on the active call; returns its stream_id. |
/api/sessions/:peer_id/calls/video/stop |
POST | Close the video sub-stream, keeping audio live. |
/api/sessions/:peer_id/calls/video/mute |
POST | { off } — toggle local camera on/off. |
/api/sessions/:peer_id |
DELETE | Close a live session. |
/api/chat_messages/:msg_id |
DELETE / POST | Delete one stored chat message, or { body } to edit it. |
/api/pair/start |
POST | { rendezvous_url, label, code? } — return the canonical pairing code (generated if code is omitted). |
/api/pair/wait |
POST | { rendezvous_url, code, label } — long-poll the pairing room; on success, save the friend and return their row. |
/api/friends |
GET | List saved friends for the active persona — [{ id, label, master_pubkey, last_peer_id, friend_x25519_pubkey, auto_connect, added_at }]. |
/api/friends/:id |
DELETE | Forget a saved friend. |
/api/friends/:id |
POST | { label } — rename a saved friend. |
/api/accounts |
GET | Local UI profiles. |
/api/accounts |
POST | { name, theme_slug? } — create a UI profile. |
/api/accounts/:id/theme |
POST | { theme_slug } — set a UI profile's theme. |
/api/themes |
GET | Built-in + custom themes. |
/api/themes |
POST | { name, slug?, created_by?, palette } — save a custom theme. |
/api/ws |
GET (upgrade) | WebSocket session stream. Server → client: snapshot plus session open/close/heartbeat, message/read/typing, edit/delete/clear, file-transfer progress, and call lifecycle (offered/accepted/rejected/ended/mute) as JSON; plus binary frames carrying decrypted Opus audio (tag 0x01) and VP9 video (tag 0x02) so encoded media skips JSON/base64. Client → server: typing_start/typing_stop, delete_local/delete_mutual, edit_mutual, set_auto_connect, call_mute, disconnect. |
| Route | Method | Purpose |
|---|---|---|
/r/health |
GET | Liveness — { status, role, version }. |
/r/time |
GET | Server's Unix time in ms; used by peers for clock sync before punching. |
/r/publish |
POST | { room, blob, owner? } — store an opaque blob in a room. |
/r/unpublish |
POST | { room, owner } — drop the entry published under owner. Called by the peer app on room leave / shutdown. |
/r/fetch/:room |
GET | Return all non-stale blobs in a room (entries TTL ~10 min). |
RECON2X_PORT— override the default port (8787for peer app,8788for--server).RECON2X_NO_BROWSER— set to anything to skip auto-opening the browser.RECON2X_DATA— directory for identity / state (default:.recon2x/next to the binary).RECON2X_STUN— comma-separated list of extra STUN servers (host:port,host:port).RECON2X_FFMPEG— optional path to anffmpegbinary for chat media previews.RUST_LOG— log verbosity (e.g.debug,recon2x_backend=trace).
Recon2x is a single executable. Run it, and it starts a local server and opens
your browser at the app. Stop it with Ctrl+C in its terminal (or just close
the window). Nothing to install.
chmod +x recon2x-linux-x64 # one-time: mark it runnable
./recon2x-linux-x64chmod +x recon2x-macos-arm64 # one-time: mark it runnable
./recon2x-macos-arm64The binary is unsigned, so the first launch is blocked by Gatekeeper. Either:
- Finder: right-click the file → Open → Open in the dialog, or
- Terminal:
xattr -d com.apple.quarantine recon2x-macos-arm64once, then run it normally.
(Apple Silicon Macs only for now — an Intel Mac build can be added to CI on request.)
Double-click recon2x-windows-x64.exe, or from a terminal:
.\recon2x-windows-x64.exeSmartScreen may warn that it's from an unknown publisher (the binary is unsigned). Click More info → Run anyway. A console window stays open while the app runs — closing it stops Recon2x.
If 8787 is taken, pick another port:
RECON2X_PORT=9000 ./recon2x-linux-x64 # Linux / macOS$env:RECON2X_PORT=9000; .\recon2x-windows-x64.exe # Windows PowerShellRunning from source instead of a prebuilt binary? See Development for dev mode, or Building the desktop bundle to produce the executable yourself.
Both peers need the same rendezvous server URL.
- Each peer runs the binary; the UI opens in their browser at the Login screen.
- First time on this machine: click Create persona, pick a label and passphrase. The app shows the 24-word mnemonic once — write it down, it's the only backup. From then on, launching the app drops you on Login; enter the passphrase to unlock.
- After unlock you land on Chats — the main surface, which holds both the connection controls (rendezvous, discovery, pairing, peer list) and the live conversation panel. Use the topbar to switch between Chats, Media, and Tester, or to switch personas / sign out (locking the app).
- Optional but recommended — pair as friends once. On Chats, open
Pair Friend. One side types a label and clicks Generate code —
the app shows a 4-word code like
apple-river-storm-clay. The other side types the same label and code and clicks Join with code. After a few seconds both apps save each other in the friends list, and from then on both apps recognise each other by cryptographic identity regardless of which rendezvous room or IP either side is on. Pairing is scoped to the active persona. - On Chats, both enter the same rendezvous URL (e.g.
https://rend.vardhin.com) and the same word (any string — it just names a room). Choose Manual, Auto, or Parked, then click Join. By default the room shows paired friends only; the debug toggle reveals unpaired plaintext rows so two unpaired peers can find each other to pair. - In Manual mode, one side clicks Connect on the other's row. The other side gets an Accept / Decline prompt. In Auto mode, paired-friend requests are accepted immediately with a short undo toast. In Parked mode, paired friends can use Reach now (parked) during the scheduled v6 window, with a normal rendezvous request as fallback.
- Once accepted, both apps automatically run the IPv6 direct → IPv4 punch → LAN/Bluetooth direct ladder; the UI shows a per-stage trace and opens the chat panel for the live session right there on Chats. The Tester page shows the same trace plus the rendezvous activity log.
- With a live session you can chat, send files (streamed, with a live
progress bar and inline playback for media), and start an audio or video
call — the call opens the full-screen
/call/:peersurface, and an incoming call rings with a banner on whatever page you're on.
If both peers have IPv6, you'll see ipv6-direct ✓ in under a second. If
both are IPv4-only, the IPv4 punch path runs (~5–40 s, sometimes never on
two strict NATs — that's a known fundamental limit).
The peer app keeps a small amount of state under $RECON2X_DATA
(default: .recon2x/ next to the binary):
identities/<slug>/identity.enc— the encrypted mnemonic vault for that persona (argon2id + ChaCha20-Poly1305). Without the passphrase this file reveals nothing.identities/<slug>/profile.json— that persona's random anonymouspeer_id.active.txt— the currently selected persona slug.recon2x.sqlite— themes, UI accounts, persona-scoped saved friends, and persona-scoped chat history (message bodies are encrypted at rest with the unlocked persona's key — seechat_crypto).vault/outbound/<persona>/<friend>/...andvault/inbound/...— sealed file bytes plus per-stream resume state (.partial+.bitmap) for in-flight and retained file transfers (seemedia_vault).
Older single-persona layouts with flat plaintext identity.txt /
profile.json are migrated into identities/default/ on first launch — the
mnemonic is re-sealed into identity.enc the first time you set a passphrase.
While changing code, don't rebuild the bundle each time (~40s). Run the two halves separately with hot reload, in two terminals:
# Terminal 1 — frontend (hot reload)
bun install
bun run dev
# Terminal 2 — backend
cd backend
cargo runThen open http://localhost:5173 — the SvelteKit dev server. Editing a
.svelte file updates the browser instantly; after editing Rust, re-run
cargo run. In dev the frontend calls the backend at :8787 (CORS is
permissive).
Run the backend tests with cargo test from backend/.
To test chat and calls between two local peers on one machine, see
docs/local-two-instance-testing.md.
Which way should I run it?
| You want to… | Do this |
|---|---|
| Use the finished app | Run the prebuilt binary — see Running Recon2x. |
| Change code / iterate | Dev mode above; open :5173. |
| Test the real bundled experience | Build the bundle, then run the dist/ binary. |
| Build the rendezvous server only | cd backend && cargo build --release --no-default-features (no frontend). |
Produce a single self-contained executable for the current OS:
scripts/bundle.shThe result lands in dist/ (e.g. dist/recon2x-linux-x64). Run it directly —
it serves the app and opens your browser. Nothing to install.
From Linux you can also cross-build for Windows if the mingw toolchain is present:
scripts/bundle.sh windows # needs mingw-w64macOS cannot be cross-built from Linux (Apple SDK is required). Use CI for Mac binaries — see Releasing.
scripts/release.sh cuts a versioned release. It does not build anything locally — it tags the current commit and pushes the tag, which fires .github/workflows/release.yml. CI then builds the binaries for Linux, macOS, and Windows on their native runners and publishes a GitHub Release with OS-specific downloads attached.
scripts/release.sh 0.1.1
# or with release notes:
scripts/release.sh 0.1.1 --notes "Adds Connect button with stage-trace UI"Before tagging, the script validates that the working tree is clean, you're on
main, the version is well-formed (X.Y.Z or X.Y.Z-rc1), the tag doesn't
already exist locally or on origin, and origin/main matches your local
main. It will refuse and explain if any check fails — overrides with
--allow-dirty / --skip-checks exist but are not recommended.
Once CI is running, watch it live (needs the gh CLI, authenticated):
scripts/release.sh --watchOr open the Actions URL the script prints. After ~8–12 minutes the Release
appears at https://github.com/<owner>/<repo>/releases/tag/vX.Y.Z with
downloads like:
recon2x-linux-x64recon2x-linux-x64.tar.gzrecon2x-macos-arm64recon2x-macos-arm64.tar.gzrecon2x-macos-x64recon2x-macos-x64.tar.gzrecon2x-windows-x64.exerecon2x-windows-x64.zipSHA256SUMS.txt
Binaries are unsigned. First-launch warnings are normal — see the per-OS sections in Running Recon2x.
A rendezvous server is what lets two peers find each other. It's the same
binary as the peer app, run with --server — it serves a tiny HTTP API at
/r/* and nothing else.
For a Raspberry Pi behind CGNAT, the full deploy (including the Cloudflare Tunnel that defeats CGNAT and a systemd service so it survives reboots) is written up step by step in docs/pi-setup.md. Short version:
# On the host (Pi, VPS, …)
cd backend
cargo build --release --no-default-features
RECON2X_PORT=8788 ./target/release/recon2x-backend --server--no-default-features skips embedding the frontend — the rendezvous server
has no UI of its own, so it doesn't need (and on a Pi probably doesn't have)
the SvelteKit build.
The persona, friends-first, sealed-presence, Auto, Parked, encrypted-vault login, encrypted-at-rest chat, streaming attachments, audio/video calls, and multi-page UI rollouts are now shipped and documented above. Remaining work is mostly hardening, security upgrades, and distribution polish:
- Reduce plaintext bootstrap exposure. Friend-targeted announcements are sealed, but the app still publishes a plaintext compatibility copy so new peers can pair and older clients can interoperate. A stricter mode could publish plaintext only when explicitly showing unpaired peers.
- Cross-host verification of the connect + call paths. Local two-peer tests hit a router-hairpin limit; IPv6 direct, IPv4 punch, parked-v6, and the live call media paths still deserve repeated testing on real separate networks.
- IPv4 parked mode (step 10). Parked presence is v6-only today; an IPv4 variant via timed rendezvous attendance is still open.
- PAKE upgrade for pairing. The current 4-word code (~32 bits) is protected by the 10-minute room TTL. SPAKE2 or similar would improve the offline-brute-force margin without changing the user flow.
- Live WebSocket punch signalling.
/api/wsalready carries session, chat, file-progress, and call events; using it for connect-requests and accepts too would replace the remaining ~2.5s rendezvous poll latency. - Sign macOS / Windows binaries. Eliminates the first-launch "unknown developer" warnings. Needs paid signing certs.
- Multi-node rendezvous replication. Today the registry is one host; a small replicated layer would survive a single node going down without changing the peer-facing API.
- Relay / translator mode, only if the no-relay goal changes. Strict symmetric NATs and IPv4-only ↔ IPv6-only pairs cannot be solved peer-to-peer; they require a relay or translator in the path.
TBD.