erl_iroh#
Distributed Erlang over iroh P2P, with optional classic TCP + TLS, and a small bot powers SDK.
Bots import the crate, get an iroh endpoint (public-key dialing, holepunch, QUIC), speak the real Erlang distribution handshake (erl_dist), and unlock capability-gated operations: RPC, send, introspect, cluster, eval, trace.
Bot SDK (powers: rpc | send | introspect | cluster | listen | eval | trace)
│
Session ── erl_dist handshake + message channel + net ticks + erpc
│
Transport
├─ iroh public-key P2P (QUIC, TLS 1.3 built-in, relays + holepunch)
├─ tcp classic BEAM / EPMD
└─ tls TCP + rustls (mTLS welcome, security pedants)
Why#
- NAT-friendly mesh of BEAM-adjacent bots without babysitting IPs — dial
EndpointIds. - Wire-compatible distribution so a bot can also talk to real OTP nodes (TCP/TLS + cookie).
- Powers keep each bot least-privileged: a scout does not need
TRACEjust toRPC.
Quick start#
Nix (flake app)#
# in-process two-bot iroh handshake demo
nix run git+https://tangled.org/nandi.uk/erl-iroh
# or from a checkout:
nix run .#demo
# long-running listener / dialer
nix run .#bot -- --name alpha
nix run .#peer -- --name beta --peer <endpoint-id>
Cargo#
# two bots in one process (handshake over local iroh)
cargo run --bin erl-iroh-demo
# same as: cargo run --example two_bots
# or two terminals
cargo run --bin erl-iroh-bot -- --name alpha
cargo run --bin erl-iroh-peer -- --name beta --peer <endpoint-id-from-alpha>
As a library#
use erl_iroh::bot::Bot;
use erl_iroh::powers::Powers;
use erl_iroh::term;
use erl_iroh::TlsConfig;
#[tokio::main]
async fn main() -> erl_iroh::Result<()> {
let bot = Bot::builder("scout")
.cookie("super-secret-cookie")
.powers(Powers::worker()) // rpc|send|introspect|cluster
.tls(TlsConfig::client_webpki()?) // optional classic path
.build()
.await?;
bot.listen_iroh().await?;
println!("dial me: {}", bot.endpoint_id());
// P2P
// bot.connect_iroh("…endpoint id…").await?;
// Real BEAM via EPMD + TCP
// bot.connect_tcp("app@localhost", None).await?;
// Real BEAM via TLS
// bot.connect_tls("app@host", "host", 4370, "host", None).await?;
// Powers
// let procs = bot.processes("app@localhost").await?;
// bot.send("app@localhost", "logger", term::atom("hi").into()).await?;
// let v = bot.eval("app@localhost", "1 + 1").await?;
Ok(())
}
Powers#
| Power | Gates |
|---|---|
RPC |
rpc, ping |
SEND |
send to registered names |
INTROSPECT |
processes, applications, ets_tables, … |
CLUSTER |
connect_iroh, connect_tcp, connect_tls |
LISTEN |
listen_iroh, accept_tls |
EVAL |
eval (scan/parse/eval over RPC) |
TRACE |
reserved for dbg/recon-style tracing |
Presets: Powers::all(), Powers::worker(), Powers::observer().
Security model#
- iroh path — every connection is QUIC with TLS 1.3; peers authenticate by ed25519
EndpointId. The Erlang cookie still gates the dist handshake. - TLS path — rustls (ring) over TCP; optional mutual TLS via
TlsConfig::server(..., Some(client_ca))andTlsConfig::client_with_ca(...). - TCP path — plain sockets for local/dev; cookie is the only auth. Prefer TLS or iroh off-localhost.
- Powers — application-level capability checks before sensitive ops.
Dev-only: TlsConfig::client_insecure() skips cert verification (self-signed labs).
Protocol notes#
- ALPN:
erl-iroh/1 - Handshake: OTP 23+ (
HANDSHAKE_23, protocol v6), includingNAME_MEdynamic names - RPC:
SPAWN_REQUEST→erpc:execute_call/4(same approach aserl_rpc) - Ticks every 15s to keep links alive
Real OTP nodes do not speak iroh natively. To bridge:
- run a bot with
LISTEN+connect_tcp/connect_tlsas a gateway, or - put a TLS terminator / TCP proxy in front of
erldistribution ports.
Layout#
src/
lib.rs crate root, ALPN
bot.rs Bot + BotBuilder SDK
powers.rs capability bitflags
session.rs handshake, channel, ticks, RPC
stream.rs clonable futures-io duplex (iroh/tls/tcp → erl_dist)
transport/
iroh.rs Endpoint bind / dial / accept
tcp.rs TCP + EPMD PORT_PLEASE2
tls.rs rustls client/server
term.rs ETF helpers
error.rs
examples/
bot.rs listen forever
peer.rs dial + send
two_bots.rs in-process mesh smoke test
License#
MIT OR Apache-2.0