A self-healing peer-to-peer mesh network with end-to-end encrypted chat, built in Rust.
Nodes form a degree-constrained graph (2–3 connections each), propagate membership state via Scuttlebutt anti-entropy gossip, disseminate messages via Plumtree epidemic broadcast trees, and auto-discover peers on LAN or connect via seed peers over Tailscale or any routable network.
The encrypted chat layer provides:
- Ed25519 identity keypairs (persistent across restarts)
- Per-recipient encryption (ECIES: ephemeral X25519 + ChaCha20-Poly1305)
- Invite-only membership — new users must be invited by an existing member
- Key revocation — compromised or unwanted keys can be permanently blocked
- Nickname propagation via Scuttlebutt gossip
- In-memory message history with peer-to-peer sync
- Rust 1.75+ (install via rustup)
- For remote chat: Tailscale installed on all participants' machines (free for personal use)
git clone <repo-url> && cd mesh-node
cargo build --releaseThis produces two chat binaries:
target/release/mesh-chat-tui— Full terminal UI (recommended)target/release/mesh-chat— Minimal stdin/stdout chat
This walks through the complete flow from zero to a working encrypted group chat.
Alice is the first person. She runs the TUI:
./target/release/mesh-chat-tuiOn first launch, the app:
- Generates a keypair — saved to
~/.config/mesh-chat/identity.key(reused on every future launch) - Asks for a nickname — e.g.
alice - Asks for a seed peer — Alice presses Enter (no seed — she's starting the network)
- Saves config to
~/.config/mesh-chat/config.toml - Starts the mesh node and enters the chat view
Alice sees her identity:
You are: alice [a1b2c3d4]
Listening on: 0.0.0.0:9000
Alice presses Ctrl+E to view her full public key and shares it with Bob (via text, email, etc.). She also tells Bob her Tailscale hostname (e.g. alice-macbook.tail1234.ts.net).
Bob runs the TUI on his machine:
./target/release/mesh-chat-tuiOn first launch:
- Generates his own keypair
- Picks nickname
bob - Enters Alice's address as seed peer:
alice-macbook.tail1234.ts.net:9000
Bob's node connects to Alice, but Alice's node rejects the connection — the network is invite-only. Bob sees "Recovering..." in the status bar. This is expected.
Bob presses Ctrl+E to view his public key and sends the 64-character hex string to Alice.
In Alice's TUI, she invites Bob by his public key. Either:
- Press Ctrl+I, paste Bob's 64-char hex key, press Enter
- Or type:
/invite <bob's-64-char-hex-pubkey>
The invitation propagates through Scuttlebutt gossip. Within seconds, Bob's next connection attempt succeeds. Bob sees:
* alice joined
They can now chat.
Charlie wants to join. The process is the same:
- Charlie runs
mesh-chat-tui, picks a nick, enters any existing member's address as seed (Alice's or Bob's — doesn't matter) - Charlie shares his pub key with any existing member
- Any existing member invites Charlie:
/invite <charlie's-pubkey> - Charlie's node connects and joins the mesh
Key point: Charlie only needs to seed to ONE existing node. Scuttlebutt gossip automatically propagates all membership info — Charlie will discover Alice, Bob, and everyone else within seconds.
Everyone just runs mesh-chat-tui from now on. The config and identity persist across restarts. As long as at least one seed peer is reachable, the node rejoins the mesh automatically.
Nodes auto-discover each other via UDP broadcast on the discovery port (default 9999). No seed peers needed — just start multiple instances:
# Terminal 1
mesh-chat-tui # nick: alice, no seed
# Terminal 2
mesh-chat-tui # nick: bob, no seed — discovered via UDPTailscale gives every machine a stable IP and DNS name. Nodes use --seed (or the setup wizard) to find each other:
# Alice (100.64.1.1 / alice-macbook.tail1234.ts.net)
mesh-chat-tui
# Bob seeds to Alice by Tailscale MagicDNS name
mesh-chat-tui # seed: alice-macbook.tail1234.ts.net:9000DNS names are resolved at startup, so Tailscale MagicDNS names work in the seed_peers config too.
Any machine with a reachable IP works. One node needs to be reachable (public IP, port forward, VPS, etc.), and others seed to it. Tailscale just removes that friction.
If someone's key is compromised or they should no longer have access:
/revoke <64-char-hex-pubkey>
The revocation propagates to all nodes via Scuttlebutt. The revoked key is permanently rejected from connecting — even if a valid invitation exists. Any member can revoke any key.
| File | Purpose |
|---|---|
~/.config/mesh-chat/identity.key |
32-byte Ed25519 seed (binary). Your identity. Back this up. |
~/.config/mesh-chat/config.toml |
Nickname, seed peers, network settings |
nick = "alice"
port = 9000 # TCP port (0 = auto-assign)
discovery_port = 9999 # UDP broadcast port for LAN discovery
bind_addr = "0.0.0.0" # Bind address (use Tailscale IP to restrict)
wan_mode = false # Relaxed timing for high-latency links
seed_peers = [
"bob-laptop.tail1234.ts.net:9000",
"charlie-desktop.tail1234.ts.net:9000",
]You can add more seed peers here after initial setup — they'll be used on the next launch.
| Field | Default | Description |
|---|---|---|
bind_addr |
0.0.0.0:9000 |
TCP address for peer connections |
discovery_port |
9999 |
UDP port for LAN broadcast discovery |
min_degree |
2 |
Minimum connections per node |
max_degree |
3 |
Maximum connections per node |
keypair_seed |
None |
Deterministic key seed (None = random) |
require_invitation |
false |
Require invitation for incoming connections |
gossip_interval |
1s |
Scuttlebutt gossip round interval |
heartbeat_interval |
500ms |
Heartbeat bump interval |
phi_threshold |
8.0 |
Phi accrual failure detection threshold |
rebalance_interval |
5s |
Topology audit interval |
ihave_timeout |
500ms |
Plumtree IHave graft timeout |
max_discovery_retries |
10 |
Discovery attempts before going offline |
recovery_base_delay |
500ms |
Base delay for exponential backoff recovery |
connect_timeout |
5s |
Outbound TCP connect timeout |
| Key | Action |
|---|---|
| Type + Enter | Broadcast message to all |
/dm <nick> <msg> |
Send encrypted direct message |
/invite <hex> |
Invite a pub key to the network |
/revoke <hex> |
Revoke a pub key |
/members |
Show member list |
/me |
Show your identity |
/help |
Show help |
/quit |
Exit |
| Ctrl+I | Invite popup |
| Ctrl+M | Member list popup |
| Ctrl+E | Your identity popup |
| Ctrl+H | Help popup |
| PgUp / PgDn | Scroll message history |
| Ctrl+C | Quit |
A minimal alternative to the TUI for scripts or simple use:
# Start a network
mesh-chat --nick alice --bind 127.0.0.1 --port 9000
# Join with seed
mesh-chat --nick bob --bind 127.0.0.1 --seed alice-macbook.ts.net:9000
# With persistent identity
mesh-chat --nick alice --keypair-seed $(cat ~/.config/mesh-chat/identity.key | xxd -p -c 64)
# Invite-only mode
mesh-chat --nick alice --invite-onlyMessages are typed directly — each line is a broadcast. Use /dm <nick> <msg> for direct messages, /invite <hex> to invite, /members to list peers.
use mesh_node::{MeshConfig, MeshEvent, MeshNode};
let node = MeshNode::start(MeshConfig {
keypair_seed: Some(seed),
require_invitation: true,
..Default::default()
}).await?;
// Identity
node.set_nick("alice").await?;
let pub_key: [u8; 32] = node.pub_key();
// Encrypted chat
node.send_chat(b"hello everyone").await?;
node.send_direct_chat(peer_id, b"secret").await?;
let history = node.chat_history(0).await;
// Membership management
node.invite(other_pub_key).await?;
node.revoke(bad_pub_key).await?;
let members = node.members().await; // Vec<MemberInfo> with node_id, pub_key, nick
let conns = node.connections().await; // directly connected peer IDs
// Raw broadcast (non-chat, application-level)
node.broadcast(b"raw payload").await?;
node.send(peer_id, b"raw to peer").await?;
// Events
let mut events = node.subscribe();
while let Ok(event) = events.recv().await {
match event {
MeshEvent::ChatMessageReceived { sender_pub_key, plaintext, .. } => { /* ... */ }
MeshEvent::NodeJoined(id) => { /* ... */ }
MeshEvent::NodeLeft(id) => { /* ... */ }
MeshEvent::BroadcastReceived { payload, .. } => { /* raw broadcast */ }
_ => {}
}
}
node.shutdown().await?;MeshNode (orchestrates lifecycle, exposes public API)
│
├─ ChatActor ─── encrypt/decrypt chat, history, sync
├─ MessageRouter ── demuxes TCP by variant ──┬─► MembershipActor (Scuttlebutt + phi failure detector)
│ ├─► PlumtreeActor (epidemic broadcast tree)
│ └─► TopologyActor (graph + rebalance + invitation check)
├─ DiscoveryActor (UDP broadcast + seed peer connection)
└─ Event Bus (broadcast channel) ◄── all actors emit/subscribe
- crypto — Ed25519 keygen/sign/verify, Ed25519→X25519 derivation, ECIES encrypt/decrypt (ephemeral X25519 DH + SHA-256 KDF + ChaCha20-Poly1305)
- invitation — Invitation and revocation creation/verification, Scuttlebutt key format
- chat — ChatMessage/EncryptedEnvelope wire format, in-memory history, ChatActor (encrypt outgoing, decrypt incoming, history sync)
- membership — Scuttlebutt anti-entropy for cluster state (pub_key, nick, invitations, revocations), phi accrual failure detector for node death detection
- plumtree — Epidemic Broadcast Trees for efficient message dissemination (Gossip/IHave/Graft/Prune protocol)
- topology — Degree-constrained adjacency graph, automatic rebalancing, invitation and revocation enforcement on connect
- discovery — UDP broadcast for LAN, seed peer connection for WAN, exponential backoff recovery
- transport — TCP connection pool with length-prefixed framing, UDP with
SO_REUSEPORT
All messages serialized with bincode:
| Category | Messages | Transport |
|---|---|---|
| Discovery | DiscoveryRequest, DiscoveryResponse |
UDP |
| Scuttlebutt | ScuttlebuttDigest, ScuttlebuttDelta |
TCP |
| Plumtree | Gossip, IHave, Graft, Prune |
TCP |
| Topology | ConnectRequest, ConnectAccept, ConnectReject |
TCP |
| Lifecycle | Leave |
TCP |
| Chat | ChatMessage, SyncRequest, SyncResponse |
TCP (via Plumtree, 0xC4 prefix) |
# Unit tests (crypto, invitation, membership, plumtree, topology, codec, router)
cargo test --lib
# Integration tests (multi-node discovery, broadcast, encrypted chat, invitations, history sync)
cargo test --test integration -- --test-threads=1
# All tests
cargo test -- --test-threads=1Integration tests must use --test-threads=1 — they bind to fixed UDP discovery ports and will fail with AddrInUse if run in parallel.
- 62 unit tests: crypto roundtrips, sign/verify, invitation create/verify/tamper, Scuttlebutt anti-entropy, phi accrual detector, Plumtree state machine, protocol codec, message routing
- 14 integration tests: single node lifecycle, two-node discovery (UDP + seed), three-node cluster formation, Plumtree broadcast delivery, graceful leave, encrypted broadcast chat, direct message delivery, invitation accept/reject, history sync on join
mesh-node/
├── Cargo.toml
├── src/
│ ├── lib.rs # Public API re-exports
│ ├── main.rs # mesh-node CLI binary
│ ├── config.rs # MeshConfig
│ ├── node.rs # MeshNode orchestrator
│ ├── identity.rs # NodeId, NodeInfo (with pub_key)
│ ├── event.rs # MeshEvent enum
│ ├── error.rs # Error types
│ ├── crypto.rs # Ed25519, X25519, ECIES, hex utils
│ ├── invitation.rs # Invitation + Revocation (create/verify)
│ ├── router.rs # Stateless TCP message demuxer
│ ├── chat/
│ │ ├── actor.rs # ChatActor: encrypt, decrypt, sync
│ │ ├── message.rs # ChatMessage, EncryptedEnvelope, wire format
│ │ └── history.rs # In-memory append-only message store
│ ├── membership/
│ │ ├── actor.rs # Scuttlebutt gossip + MembershipQuery handler
│ │ ├── state.rs # ScuttlebuttState (per-node KV namespaces)
│ │ └── phi.rs # Phi accrual failure detector
│ ├── plumtree/
│ │ ├── actor.rs # Plumtree epidemic broadcast
│ │ └── state.rs # Eager/lazy peer sets, IHave timers, message cache
│ ├── topology/
│ │ ├── actor.rs # Connect/leave/rebalance + invitation/revocation check
│ │ └── graph.rs # Adjacency list with degree constraints
│ ├── discovery/
│ │ ├── actor.rs # Discovery lifecycle (broadcast + seed)
│ │ └── broadcast.rs # UDP discover_peers + spawn_discovery_responder
│ ├── transport/
│ │ ├── tcp.rs # TCP listener, connection pool, framed I/O
│ │ └── udp.rs # UDP socket, broadcast, SO_REUSEPORT
│ └── bin/
│ ├── mesh_chat.rs # Minimal stdin/stdout chat client
│ └── mesh_chat_tui/ # Full TUI chat client (ratatui)
│ ├── main.rs
│ ├── app.rs # App state, run loop, event handling
│ ├── config.rs # Identity + config persistence
│ └── ui/
│ ├── mod.rs
│ ├── theme.rs # Tailwind color palette
│ ├── chat.rs # Messages + input + sidebar
│ ├── setup.rs # First-run wizard
│ └── popup.rs # Invite, members, help, identity
└── tests/
└── integration.rs # Multi-node integration tests
- Identity: Ed25519 keypairs. The 32-byte seed in
identity.keyIS your identity — treat it like a private key. - Encryption: Every chat message is encrypted per-recipient using ECIES (ephemeral X25519 Diffie-Hellman + SHA-256 KDF + ChaCha20-Poly1305). Only the intended recipient can decrypt.
- Invitation: Signed by the sponsor's Ed25519 key, verified by all nodes. Propagated via Scuttlebutt.
- Revocation: Same mechanism as invitations. Any member can revoke any key. Revocation overrides invitation.
- Transport: Messages travel over TCP in cleartext (bincode). The encryption is at the application layer (chat payloads), not the transport layer. For transport security, use Tailscale (WireGuard) or another encrypted tunnel.
- No forward secrecy: A compromised long-term key can decrypt past messages if ciphertext was captured. Forward secrecy via ratcheting is planned for v2.
- Trust model: Any member can invite or revoke anyone (one-approver). There is no consensus or voting — this is designed for small trusted groups, not adversarial environments.