Minimal Yggdrasil client for embedded devices.
Implements the Yggdrasil/Ironwood protocol subset needed to operate as a leaf node: connect to peers, join the spanning tree, discover paths via bloom filters, and exchange end-to-end encrypted packets with any node in the network. No TUN interface, no admin API.
By default there is no transit routing. The optional transit Cargo feature turns the node into a router: it merges peer bloom filters into its advertisements, forwards path discovery, and forwards traffic between peers. Individual peers can be excluded with set_peer_no_redistribute(peer_id, true) — their keys are never redistributed to other peers (no bloom merge, no lookup forwarding toward them), while the marked peer can still use the node as an uplink. Marking all peers makes a multi-homed stub that never carries third-party traffic.
Designed for no_std + alloc environments (ESP32 with Embassy), but also builds with std for desktop testing.
- Spanning tree participation (announce, SigReq/SigRes, parent selection)
- Bloom filter routing (advertise own key, respond to PathLookup)
- Path discovery (PathLookup / PathNotify / PathBroken)
- Optional transit routing (
transitfeature) with per-peerno_redistributepolicy - End-to-end encrypted sessions (XSalsa20-Poly1305 with key ratcheting)
- Metadata handshake compatible with yggdrasil-go and yggdrasil-ng
- IPv6 address derivation from Ed25519 public key (
200::/7) - Poll-based, synchronous API — no async runtime dependency
- ~2500 lines of Rust, 73 unit tests
Application
│
▼
YggdrasilLite ◄── poll-based node coordinator
├── tree.rs ◄── spanning tree CRDT (parent selection, announces)
├── bloom.rs ◄── bloom filter exchange (key advertisement)
├── pathfinder.rs ◄── path cache (PathLookup/PathNotify/PathBroken)
├── session.rs ◄── encrypted sessions (Init/Ack/Traffic + ratcheting)
├── wire.rs ◄── frame encoding/decoding (uvarint, TLV, paths)
├── meta.rs ◄── metadata handshake (version, key, BLAKE2b sig)
├── crypto.rs ◄── Ed25519, X25519, XSalsa20-Poly1305
├── address.rs ◄── IPv6 address derivation from ed25519 key
└── peer.rs ◄── per-peer connection state
use yggdrasil_lite::{YggdrasilLite, LiteConfig, NodeEvent};
// Create a node with an Ed25519 seed
let config = LiteConfig::new(my_ed25519_seed);
let mut node = YggdrasilLite::new(config);
// After TCP+TLS connection and metadata handshake with a peer:
let peer_id = node.add_peer(peer_public_key, 0);
node.mark_handshake_done(peer_id);
// Main loop
loop {
// Feed incoming data from the peer's TLS stream
let events = node.handle_peer_data(peer_id, &incoming_bytes, now_ms, &mut rng);
handle_events(&events, &mut tls_writer);
// Periodic maintenance (~every 100ms)
let events = node.poll(now_ms, &mut rng);
handle_events(&events, &mut tls_writer);
// Send encrypted data to a remote Yggdrasil node
let events = node.send(&dest_public_key, b"hello", now_ms, &mut rng);
handle_events(&events, &mut tls_writer);
}
fn handle_events(events: &[NodeEvent], writer: &mut impl std::io::Write) {
for event in events {
match event {
NodeEvent::SendToPeer { data, .. } => {
writer.write_all(data).unwrap();
}
NodeEvent::Deliver { source, data } => {
// Decrypted application data from a remote node
println!("Received {} bytes from {:?}", data.len(), &source[..4]);
}
}
}
}| Field | Type | Description |
|---|---|---|
private_key |
[u8; 32] |
Ed25519 signing key seed |
password |
Option<Vec<u8>> |
Peering password (if peers require one) |
max_sessions |
usize |
Max concurrent encrypted sessions (default: 16) |
max_paths |
usize |
Max cached path entries (default: 16) |
| Method | Description |
|---|---|
new(config) |
Create a new node |
public_key() |
Our Ed25519 public key |
address() |
Our Yggdrasil IPv6 address (200::/7) |
subnet() |
Our /64 subnet (300::/7) |
coords() |
Current tree coordinates |
add_peer(key, priority) |
Register a peer after TLS+metadata handshake |
remove_peer(peer_id) |
Remove a disconnected peer |
mark_handshake_done(peer_id) |
Mark peer as fully connected |
handle_peer_data(peer_id, data, now_ms, rng) |
Process incoming wire data from a peer |
poll(now_ms, rng) |
Periodic maintenance (tree, bloom, keepalive, cleanup) |
send(dest_key, data, now_ms, rng) |
Send encrypted data to a destination |
| Variant | Description |
|---|---|
SendToPeer { peer_id, data } |
Write data to the peer's TLS stream |
Deliver { source, data } |
Decrypted application data from source |
The caller is responsible for TCP+TLS connection setup. After TLS, exchange metadata using meta::Metadata:
use yggdrasil_lite::meta::Metadata;
// Encode and send our metadata
let meta = Metadata::new(node.public_key().clone(), 0);
let encoded = meta.encode(&signing_key, password);
tls_writer.write_all(&encoded)?;
// Read and decode peer metadata
let (peer_meta, consumed) = Metadata::decode(&received_bytes, password)?;
assert!(peer_meta.check()); // verify protocol compatibilityyggdrasil-lite is transport-agnostic. It operates on framed byte streams — the caller provides TCP+TLS and feeds raw bytes in/out. This makes it usable with:
- Desktop:
std::net::TcpStream+rustls - Embassy:
embassy-net::TcpSocket+embedded-tls - Any other runtime that provides a byte stream
A complete desktop integration test node with TLS, metadata handshake, and a userspace TCP/HTTP server running on the Yggdrasil overlay.
# 1. Start a yggdrasil-ng node
cargo run -p yggdrasil -- --config yggdrasil.conf --loglevel debug
# Note the TLS listen address (e.g. tls://0.0.0.0:2020)
# 2. Run the lite node
cargo run --example lite_node -p yggdrasil-lite -- 127.0.0.1:2020
# 3. Test from the yggdrasil-ng host
curl -6 --max-time 30 "http://[<lite_ipv6>]:80/hello"Bridges the overlay to a real kernel TUN interface, so the host's own stack
can reach the mesh (ping6, curl -6, ssh, …) — not just a single userspace
HTTP endpoint. Needs root to create the device and add a route.
# Connect to a yggdrasil-ng / yggstack TLS peer and bring up the bridge
sudo cargo run --release -p yggdrasil-tun -- 127.0.0.1:2020
# From this host, reach any node on the mesh:
ping6 <a-yggdrasil-address>See examples/tun/ for details. It uses two small APIs for
address-initiated traffic: YggdrasilLite::lookup() (start path discovery from
a partial key derived from an IPv6 address) and YggdrasilLite::resolve()
(map a learned address back to a full public key).
A firmware for ESP32 (C6 and and 32D) that bridges TCP connections over the Yggdrasil mesh to a hardware UART. The device connects to WiFi, establishes a TLS connection to Yggdrasil peers, and listens for TCP and ICMPv6 on its overlay IPv6 address. Target chip is selected via Cargo features.
See examples/esp32/ for the full project. Quick start:
cd examples/esp32
# Edit .cargo/config.toml with your WiFi and peer settings
# Then build and flash:
# ESP32-C6 (RISC-V)
cargo run --release --target riscv32imac-unknown-none-elf --features esp32c6
# ESP32 (Xtensa) — requires esp toolchain
cargo +esp run --release --target xtensa-esp32-none-elf --features esp32 --no-default-featuresOn boot the device prints its Yggdrasil IPv6 address. You can then connect to it from anywhere on the mesh:
# Ping over Yggdrasil (via yggstack SOCKS5 proxy)
ping6 -x 127.0.0.1:1080 <esp-ipv6>
# TCP to UART bridge
nc -X 5 -x 127.0.0.1:1080 <esp-ipv6> 2000A web UI is available at http://192.168.4.1 (connect to the YggBridge AP) for configuring WiFi credentials and Yggdrasil peer addresses.
An automated E2E test verifies the full stack: yggstack → yggdrasil-ng → lite_node → HTTP.
YGGSTACK_BIN=/path/to/yggstack bash tests/e2e.shThe test starts a yggstack node with a SOCKS5 proxy, connects a lite_node to it, and verifies HTTP reachability over the Yggdrasil overlay via curl.
# Desktop (std) — tests and examples
cargo test
cargo run --example lite_node -- 127.0.0.1:12345
# ESP32-C6 (no_std) — requires nightly toolchain
cd examples/esp32
cargo build --release --target riscv32imac-unknown-none-elf --features esp32c6
# ESP32 (no_std) — requires esp toolchain
cargo +esp build --release --target xtensa-esp32-none-elf --features esp32 --no-default-featuresMPL-2.0