Default-deny peer-to-peer messaging for Elixir.
PeerNet gives you BEAM-distribution-shaped ergonomics — expose a named
handler on one node, call it from another — between mutually-suspicious
peers, with cryptographic identity, walkie-talkie delivery semantics, and no
servers.
It is not a reimplementation of Erlang distribution. It coexists with disterl on the same node, on different ports, with a fundamentally different trust model: every handle is closed by default, and peers are addressed by their public key rather than their (host, port).
Pre-release. All core layers are landed and tested:
- Identity, Trust, Handlers, Frame
- Noise XX handshake + ChaCha20-Poly1305 AEAD transport
- Acceptor, Connection, per-instance supervision
- Registry with auto-connect on discovery + auto-reconnect on disconnect
- Discovery: behaviour + Manual + UDP reference impls
- Liveness (app-level heartbeat)
- BeamDist (asymmetric-trust RPC convenience)
See PLAN.md for the milestone history, CHANGELOG.md for the change log, and guides/protocol.md for the wire format specification. guides/cookbook.md has working patterns for common use cases.
Remaining before a Hex 0.1.0 release: documentation polish + a
NetworkMonitor for IP-change events on mobile (the desktop /
Nerves use cases work without it).
The Erlang/Elixir ecosystem already has good answers to most communication problems:
- BEAM distribution — perfect ergonomics, but trust is one shared cookie per cluster. Anyone with the cookie has a remote shell on every member. Fine for trusted infrastructure; unsafe between mutually-suspicious peers.
- Phoenix Channels / WebSockets — safe, well-trodden, but client-server. Needs servers running somewhere; introduces a relay you must trust.
- WebRTC, libp2p, Iroh — peer-to-peer, but heavyweight, not BEAM-native, and designed primarily for streaming or distributed-storage workloads.
PeerNet fills the gap: BEAM-native, safe by default, peer-to-peer.
# In your supervision tree:
children = [
{PeerNet, [data_dir: "/var/lib/myapp/peer_net"]}
]
# On every node — the "server" facet. Default-deny, opt-in expose.
PeerNet.expose(:chat, fn _from, %{text: text} ->
IO.puts("got: #{text}")
:ok
end)
# Discover peers on the local network:
PeerNet.list_peers()
#=> [%{pubkey: <<...>>, status: :online, last_seen: ~U[2026-...]}]
# Pair with a peer (out-of-band, e.g. via QR):
PeerNet.pair(peer_pubkey)
# Send a message — addressed by pubkey, not IP:
PeerNet.send(peer_pubkey, :chat, %{text: "hi"})PeerNet defends against:
- Passive eavesdropping — Noise XX with ChaCha20-Poly1305 AEAD.
- Active MITM — handshake verifies the peer's static key against the trust list before completing.
- Replay — Noise nonces.
- Atom-exhaustion attacks — incoming wire is decoded with
:safeETF. - Unsolicited execution — every handle is closed by default; nothing is
reachable until explicitly
expose'd. - Impersonation — Ed25519 signatures bind every message to its sender's pubkey.
PeerNet does not defend against:
- Side-channel timing attacks.
- Traffic analysis (a passive observer can see who talks to whom and when).
- Trusted peers misbehaving — once you've added a pubkey to the trust list, you've vouched for it.
- OS-level compromise of either endpoint.
PeerNet runs alongside regular Erlang distribution on the same node — they use different ports and don't conflict.
For the case where you want full BEAM-dist semantics with a specific trusted
peer (a phone controlling a Nerves device, say), the opt-in
PeerNet.BeamDist module gives you :rpc.call-equivalent calls between
two peers who have explicitly granted each other access:
# On the Nerves device — explicit grant per peer:
PeerNet.expose(:beam_admin, &PeerNet.BeamDist.handle/2,
authorize: fn pubkey -> pubkey == @phone_pubkey end)
# On the phone — RPC-style sugar:
PeerNet.BeamDist.call(nerves_pubkey, MyMod, :restart_wifi, [])Asymmetric: only the granted peer can make calls. The Nerves device cannot reciprocally call the phone unless the phone separately grants.
When published:
def deps do
[
{:peer_net, "~> 0.1.0"}
]
endApache-2.0. See LICENSE.