Skip to content

vardhin/Recon2x

Repository files navigation

Recon2x

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 at https://rend.yourdomain.com) cannot read peer addresses.

Architecture

                      ┌─────────────────────────────┐
                      │ 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)

Connection strategies

  1. IPv6 direct — both peers have v6; no NAT to punch through. Fastest.
  2. IPv4 hole punch — both peers have v4; deterministic schedule derived from server time, both sides fire HELLO bursts at the agreed instant.
  3. 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.
  4. 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.

Presence modes — Manual / Auto / Parked

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

Why a separate Rust backend?

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.

Identity, login, and encryption at rest

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_id so 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.

Repository layout

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)

Status — what works, what's planned

net/ — networking

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.

rendezvous/ — discovery

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 ⚠️ Pairing blobs and friend-targeted presence copies are encrypted. A plaintext bootstrap/backward-compat announcement is still published so new friends and explicit "show unpaired peers" debugging can work.

identity/, invite/, vault — long-term keys + login

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

pairing.rs — word-code friend exchange

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

Connect handshake

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)

sessions.rs — live UDP sessions, chat, attachments

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 dispatchTAG_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

signaling/ — local in-memory state

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

calls.rs / media_crypto.rs — audio/video calls

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

store.rs — local SQLite

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

Frontend (src/routes/*)

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

Event-stream protocol migration

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

Migration progress

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

What lives where

  • backend/src/net/event.rs — pure wire format. Tag, all KIND_* discriminators (chat / file / audio / video), EventFlags, EventFrame encode/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 Open metadata (AudioCallMeta / VideoCallMeta) carrying the key salt.
  • backend/src/media_crypto.rs — per-call AEAD: the CallKey / CallParams derivation and per-frame seal/open with seq-in-nonce anti-replay.
  • backend/src/store.rsoutbound_queue table (encrypted-at-rest via chat_crypto::seal_bytes), the drain/seq helpers, and the per-friend auto_connect column.
  • backend/src/media_vault.rs — on-disk vault for outbound sealed files
    • inbound resume state (partial + bitmap per stream).
  • backend/src/chat_crypto.rsseal_bytes / open_bytes for binary blobs used by outbound_queue rows.

Improvisations during implementation

A few choices diverge from the design doc as written; each is called out in the doc's > Improvisations callout and recapped here:

  1. Receiver-resume for files. The doc only describes whole-file retransmit after FIN + NACK rounds. We added a per-stream <stream>.partial (sparse-allocated to encrypted_size) and a <stream>.bitmap (one byte per chunk, 0 = missing, 1 = received) on the receiver side, both under vault/inbound/.... On reconnect, a re-sent Open for an existing partial triggers an immediate EventNack listing just the missing chunks — the sender resends only what's actually needed. Tested in media_vault::tests::inbound_partial_resume.

  2. Sender vault for outbound files. §12.3 of the design doc assumes the queued Open row stores a payload_ref pointing 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 via Close { reason: "complete" }. A planned vault page in the UI will surface these files alongside received attachments.

  3. Auto-accept Open. The doc's UX (§7.1, §16) prompts the user via a StreamOffered WS event and waits for an accept_stream command. We emit StreamOffered so the frontend can show the prompt later, but for now the backend auto-acks every Open — matching today's behaviour (files just appear) and avoiding a silent hang while step 7's UI is still pending.

  4. 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 understands TAG_EVENT. Because both ends ship together (no migration path), this is a clean break rather than a compatibility window.

  5. 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's seq via MAX(seq) + 1 over the queue rows for that stream_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.

Known gaps (called out, not bugs)

  • No proactive retransmit timer. §6.3 mentions a RETRANSMIT_TIMEOUT per 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 FileFin or 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_at first.

API

Peer app (local, default port 8787)

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.

Rendezvous server (--server, default port 8788)

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

Environment variables

  • RECON2X_PORT — override the default port (8787 for peer app, 8788 for --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 an ffmpeg binary for chat media previews.
  • RUST_LOG — log verbosity (e.g. debug, recon2x_backend=trace).

Running Recon2x

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.

Linux

chmod +x recon2x-linux-x64      # one-time: mark it runnable
./recon2x-linux-x64

macOS

chmod +x recon2x-macos-arm64    # one-time: mark it runnable
./recon2x-macos-arm64

The binary is unsigned, so the first launch is blocked by Gatekeeper. Either:

  • Finder: right-click the file → OpenOpen in the dialog, or
  • Terminal: xattr -d com.apple.quarantine recon2x-macos-arm64 once, then run it normally.

(Apple Silicon Macs only for now — an Intel Mac build can be added to CI on request.)

Windows

Double-click recon2x-windows-x64.exe, or from a terminal:

.\recon2x-windows-x64.exe

SmartScreen may warn that it's from an unknown publisher (the binary is unsigned). Click More infoRun anyway. A console window stays open while the app runs — closing it stops Recon2x.

Changing the port

If 8787 is taken, pick another port:

RECON2X_PORT=9000 ./recon2x-linux-x64          # Linux / macOS
$env:RECON2X_PORT=9000; .\recon2x-windows-x64.exe   # Windows PowerShell

Running from source instead of a prebuilt binary? See Development for dev mode, or Building the desktop bundle to produce the executable yourself.

Connecting two peers — what to actually do

Both peers need the same rendezvous server URL.

  1. Each peer runs the binary; the UI opens in their browser at the Login screen.
  2. 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.
  3. 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).
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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/:peer surface, 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).

Local state

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 anonymous peer_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 — see chat_crypto).
  • vault/outbound/<persona>/<friend>/... and vault/inbound/... — sealed file bytes plus per-stream resume state (.partial + .bitmap) for in-flight and retained file transfers (see media_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.

Development

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 run

Then 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).

Building the desktop bundle

Produce a single self-contained executable for the current OS:

scripts/bundle.sh

The 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-w64

macOS cannot be cross-built from Linux (Apple SDK is required). Use CI for Mac binaries — see Releasing.

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 --watch

Or 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-x64
  • recon2x-linux-x64.tar.gz
  • recon2x-macos-arm64
  • recon2x-macos-arm64.tar.gz
  • recon2x-macos-x64
  • recon2x-macos-x64.tar.gz
  • recon2x-windows-x64.exe
  • recon2x-windows-x64.zip
  • SHA256SUMS.txt

Binaries are unsigned. First-launch warnings are normal — see the per-OS sections in Running Recon2x.

Running the rendezvous server

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.

Roadmap

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/ws already 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.

License

TBD.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors