Skip to content

addy/mesh-node

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

mesh-node

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

Quick Start

Prerequisites

  • Rust 1.75+ (install via rustup)
  • For remote chat: Tailscale installed on all participants' machines (free for personal use)

Build

git clone <repo-url> && cd mesh-node
cargo build --release

This produces two chat binaries:

  • target/release/mesh-chat-tui — Full terminal UI (recommended)
  • target/release/mesh-chat — Minimal stdin/stdout chat

Setting Up a Chat Network

This walks through the complete flow from zero to a working encrypted group chat.

Step 1: Alice starts the network

Alice is the first person. She runs the TUI:

./target/release/mesh-chat-tui

On first launch, the app:

  1. Generates a keypair — saved to ~/.config/mesh-chat/identity.key (reused on every future launch)
  2. Asks for a nickname — e.g. alice
  3. Asks for a seed peer — Alice presses Enter (no seed — she's starting the network)
  4. Saves config to ~/.config/mesh-chat/config.toml
  5. 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).

Step 2: Bob joins

Bob runs the TUI on his machine:

./target/release/mesh-chat-tui

On first launch:

  1. Generates his own keypair
  2. Picks nickname bob
  3. 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.

Step 3: Alice invites Bob

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.

Step 4: More people join

Charlie wants to join. The process is the same:

  1. Charlie runs mesh-chat-tui, picks a nick, enters any existing member's address as seed (Alice's or Bob's — doesn't matter)
  2. Charlie shares his pub key with any existing member
  3. Any existing member invites Charlie: /invite <charlie's-pubkey>
  4. 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.

Step 5: Normal usage

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.


Network Topology

LAN (same network)

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 UDP

Tailscale (recommended for remote)

Tailscale 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:9000

DNS names are resolved at startup, so Tailscale MagicDNS names work in the seed_peers config too.

Any routable network

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.


Revoking a Key

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.


Configuration

Files

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

config.toml

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.

MeshConfig (library)

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

TUI Keybindings

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

CLI Chat (mesh-chat)

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

Messages are typed directly — each line is a broadcast. Use /dm <nick> <msg> for direct messages, /invite <hex> to invite, /members to list peers.


Library API

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?;

Architecture

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

Subsystems

  • 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

Wire Protocol

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)

Testing

# 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=1

Integration tests must use --test-threads=1 — they bind to fixed UDP discovery ports and will fail with AddrInUse if run in parallel.

Test coverage

  • 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

Project Structure

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

Security Model

  • Identity: Ed25519 keypairs. The 32-byte seed in identity.key IS 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.

About

Decentralized chat

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages