irohmesh

package module
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 30, 2026 License: MIT Imports: 22 Imported by: 0

README

mlx-go-iroh

A shared transport substrate over go-iroh: the endpoint bring-up, connection, discovery, and bootstrap ritual that every go-iroh mesh consumer otherwise hand-rolls, plus two primitives layered on top — ed25519 node identity and signed-JSON gossip — and content-addressed blob and manifest helpers.

It depends only on go-iroh, golang.org/x, and the standard library. The mlx-go in the name marks the ecosystem family, not a dependency: there is no mlx-go import. A consumer pins go-iroh once, through this module.

Packages

Package What it provides
irohmesh Endpoint bring-up (Bind/Serve/Connect/ConnectID), fenced Conn (net.Conn streams), backend-agnostic Discovery + NewGossipDiscovery, RosterBootstrap/ParseBootstrap, NodeKey ed25519 identity, and SignedEnvelope signed gossip.
irohmesh/blob []byte ⇄ BAO-verified blob content-addressed store: Encode/Decode, Ticket, Serve/Fetch, and a MemoryStore.
irohmesh/manifest Pull a content-addressed set of blobs from peers, falling back to a hub when no peer can serve one: Sync.Pull with PeerFetcher/HubRepo.

The substrate fences go-iroh: callers see net.Conn streams and a Discovery interface, never the underlying iroh.Conn or gossip types.

Example

ep, err := irohmesh.Bind(ctx, irohmesh.Config{Identity: myKey.Ed25519()})
if err != nil {
	return err
}
defer ep.Close()

// Serve an application protocol; discovery is optional.
err = ep.Serve("myapp/v1", func(ctx context.Context, conn *irohmesh.Conn) error {
	s, err := conn.AcceptStream(ctx)
	if err != nil {
		return err
	}
	defer s.Close()
	// ... run a protocol over the net.Conn ...
	return nil
}, nil)

// Dial a peer you have a ticket for.
conn, err := ep.Connect(ctx, ticket, "myapp/v1")

Status

Substrate, identity, gossip, blob, and manifest layers are implemented and tested. The blob and manifest layers carry bytes only; a typed codec (for example mlx arrays) wraps them in the consuming module.

Documentation

Overview

Package irohmesh is a transport substrate over go-iroh: the endpoint bring-up, connection, discovery, and bootstrap ritual that every go-iroh mesh consumer otherwise hand-rolls, plus the two primitives layered on top of it — ed25519 node identity and signed-JSON gossip.

The substrate fences go-iroh: callers see net.Conn streams and a backend-agnostic Discovery, never the underlying iroh.Conn or gossip types. Bind a NodeKey as the endpoint identity, Serve an application ALPN, dial a peer by ticket, and run a protocol over a plain net.Conn.

The blob content-addressed store lives in github.com/tmc/mlx-go-iroh/blob and the manifest pull-with-hub-fallback in github.com/tmc/mlx-go-iroh/manifest. Both depend only on go-iroh.

irohmesh has no mlx-go dependency: its name marks the ecosystem family, not a dependency. The only imports are go-iroh, golang.org/x, and the standard library, so a consumer pins go-iroh once through this module.

Index

Examples

Constants

View Source
const MaxGossipFrame = 4096

MaxGossipFrame is the maximum size of a single gossip message payload. The go-iroh gossip layer caps frames here, which is what forces control messages to stay small (heartbeats, commands) and pushes anything large (checkpoints) onto the blob path. SignEnvelope asserts the signed payload fits.

Variables

View Source
var DefaultDiscoveryTopic = gossip.DefaultDiscoveryTopic

DefaultDiscoveryTopic is the gossip topic endpoints publish and resolve addressing on by default. It reuses go-iroh's default discovery topic; a namespaced topic is a one-line change at NewGossipDiscovery call sites.

View Source
var ErrInvalid = errors.New("irohmesh: invalid")

ErrInvalid reports invalid endpoint input or state.

Functions

func ParseBootstrap

func ParseBootstrap(s string) (netaddr.EndpointAddr, error)

ParseBootstrap parses one endpointID@transportAddr bootstrap string into a dialable EndpointAddr. Unlike RosterBootstrap's addr-less ids, this yields a seed the swarm can dial immediately.

Example

ExampleParseBootstrap parses an endpointID@transportAddr seed string.

package main

import (
	"fmt"

	irohmesh "github.com/tmc/mlx-go-iroh"
)

func main() {
	// A fixed endpoint id (lowercase hex of an ed25519 public key) and a direct
	// IP transport address.
	const seed = "632c9edea77a6d157bcff57d92adf6bb7ac99a543a3709eb4f27c2b884f93a72@ip:1.2.3.4:5678"
	addr, err := irohmesh.ParseBootstrap(seed)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(addr.Addrs()[0])
}
Output:
ip:1.2.3.4:5678

func ParseBootstraps

func ParseBootstraps(values []string) ([]netaddr.EndpointAddr, error)

ParseBootstraps parses a list of endpointID@transportAddr bootstrap strings. A single malformed entry fails the whole list, since a seed list is operator input where a typo should surface, not be silently dropped.

func PublishSigned

func PublishSigned(ctx context.Context, topic *gossip.Topic, k NodeKey, payload []byte) error

PublishSigned signs payload with k and broadcasts it to the topic. It is the send half of the signed-gossip pattern: the application serializes its own message (JSON or binary), this wraps and broadcasts it under the size bound.

func RosterBootstrap

func RosterBootstrap(rosterPubsBase64 []string, self key.EndpointID) ([]netaddr.EndpointAddr, error)

RosterBootstrap maps a roster's ed25519 public keys (base64 std encoding) to the EndpointAddrs a gossip swarm seeds its join from. self is omitted so a node never bootstraps off itself. The addresses are intentionally empty: under the unified-key model a roster pubkey IS an endpoint id, so the swarm dials members it discovers an address for, and the allow-list is the roster itself. A row that does not decode to a valid ed25519 key is skipped — fail-soft on one malformed row rather than refusing the whole mesh.

func VerifiedEnvelopes

func VerifiedEnvelopes(topic *gossip.Topic) iter.Seq2[SignedEnvelope, error]

VerifiedEnvelopes iterates the topic's received messages, decoding and verifying each as a SignedEnvelope and yielding only those that pass. A message that does not decode, fails verification, or is not a received-content event is skipped, so the iterator yields exactly the authenticated payloads. It ends when the topic's event stream ends or ctx is cancelled; a transport error from the underlying stream is yielded with a zero envelope.

func Verify

func Verify(pub ed25519.PublicKey, msg, sig []byte) bool

Verify reports whether sig is a valid signature of msg under pub. It mirrors the SignX/VerifyX pairing used across the ecosystem's signed records.

Types

type Config

type Config struct {
	BindAddr  string
	SecretKey string
	Identity  ed25519.PrivateKey
}

Config is the validated set of go-iroh endpoint inputs Bind needs.

Identity, when set, is the node's ed25519 key used directly as the go-iroh endpoint key, so the wire EndpointID is the same key a roster records and a ledger signs with. It takes precedence over SecretKey, which remains for a string-encoded key (e.g. an operator environment variable). When neither is set, Bind generates an ephemeral key.

BindAddr is an "ip:port" address; an empty value uses the go-iroh default (an ephemeral port on all interfaces).

The zero Config is usable: it binds an ephemeral endpoint with a generated identity.

type Conn

type Conn struct {
	// contains filtered or unexported fields
}

Conn is one accepted or dialed connection. It exposes the stream operations a protocol needs as net.Conn streams, so go-iroh stays fenced inside this package and callers run their protocol over a plain io.ReadWriteCloser.

func (*Conn) AcceptStream

func (c *Conn) AcceptStream(ctx context.Context) (net.Conn, error)

AcceptStream accepts the peer's next bidirectional stream as a net.Conn.

func (*Conn) Close

func (c *Conn) Close() error

Close releases the connection and its streams.

func (*Conn) OpenStream

func (c *Conn) OpenStream(ctx context.Context) (net.Conn, error)

OpenStream opens a bidirectional stream as a net.Conn. The connector side opens; the listener side accepts the matching stream with Conn.AcceptStream.

func (*Conn) RemoteID

func (c *Conn) RemoteID() key.EndpointID

RemoteID returns the verified endpoint id of the peer, used to attribute the connection to a known node.

type Discovery

type Discovery interface {
	// Start joins the discovery topic and begins publishing/resolving. It blocks
	// until ctx is cancelled, like the underlying gossip backend.
	Start(ctx context.Context) error
	// Publish advertises this node's endpoint addressing to the swarm.
	Publish(data dns.EndpointData)
	// Resolve subscribes to a peer's addressing, yielding its iroh.Item each
	// time the swarm learns a fresher address for it.
	Resolve(ctx context.Context, id key.EndpointID) iter.Seq2[iroh.Item, error]
}

Discovery is the backend-agnostic endpoint-discovery seam. Its method set mirrors go-iroh's gossip discovery so the backend is a private implementation detail: Publish advertises this node's addressing, and Resolve is a subscription that re-emits a peer's addressing as it changes.

Resolve returning iter.Seq2[iroh.Item, error] leaks the iroh.Item type, the one deliberate seam leak; discovery is intrinsically about go-iroh addressing.

func NewGossipDiscovery

func NewGossipDiscovery(ep *Endpoint, topic gossip.TopicID, bootstrap []netaddr.EndpointAddr) Discovery

NewGossipDiscovery builds a gossip-backed Discovery over a bound endpoint for the given topic. The returned Discovery carries the gossip protocol handler internally; Endpoint.Serve registers it under gossip.ALPN on the shared router, so the same endpoint serves both the application ALPN and gossip.ALPN. bootstrap is the swarm's reachable seed peers (see RosterBootstrap and ParseBootstraps).

The gossip discovery is registered as a resolver on the endpoint's lookup services, so Endpoint.ResolveAddr and Endpoint.ConnectID can dial a peer known only by id once the swarm has learned its address.

type Endpoint

type Endpoint struct {
	// contains filtered or unexported fields
}

Endpoint is a bound go-iroh endpoint. After Endpoint.Serve it also owns the iroh.Router that runs the accept loop for the application and gossip ALPNs. Construct with Bind; the zero value is unusable.

func Bind

func Bind(ctx context.Context, cfg Config) (*Endpoint, error)

Bind validates cfg and binds a go-iroh endpoint WITHOUT registering ALPNs: the router started by Endpoint.Serve owns the accept loop, so the endpoint must not already be listening. Endpoint.Connect (the dial path) works on a bound endpoint with no Serve. The caller owns Endpoint.Close.

func (*Endpoint) Addr

func (e *Endpoint) Addr() netaddr.EndpointAddr

Addr returns the endpoint's advertised address (identity plus any known transport addresses), the form a peer dials.

func (*Endpoint) Close

func (e *Endpoint) Close() error

Close shuts the endpoint down. After Serve it routes through router.Shutdown, which cancels the accept loop, runs handler Shutdown hooks, and closes the endpoint. Before Serve it closes the endpoint directly. Shutdown is idempotent, so a redundant Close is safe.

func (*Endpoint) Connect

func (e *Endpoint) Connect(ctx context.Context, ticket, alpn string) (*Conn, error)

Connect dials the peer named by ticket on alpn and returns the resulting *Conn. ticket is an endpointticket-encoded peer address that already carries dialable transport addresses. The caller owns Close on the returned connection. To dial a peer known only by its id, use Endpoint.ConnectID.

func (*Endpoint) ConnectAddr

func (e *Endpoint) ConnectAddr(ctx context.Context, addr netaddr.EndpointAddr, alpn string) (*Conn, error)

ConnectAddr dials addr on alpn. addr must carry at least one transport address (IP, relay, or custom); a bare-id addr fails, since go-iroh's Connect does not resolve addresses before dialing. Use Endpoint.ConnectID to resolve a bare id first.

func (*Endpoint) ConnectID

func (e *Endpoint) ConnectID(ctx context.Context, id key.EndpointID, alpn string) (*Conn, error)

ConnectID resolves a bare endpoint id through the lookup services and dials the resulting address on alpn. It is the global-discovery dial path: a peer is reached by id alone, with discovery supplying the address. When the only resolver is gossip, the peer must be reachable through the discovery swarm.

func (*Endpoint) DialableAddr added in v0.1.1

func (e *Endpoint) DialableAddr() netaddr.EndpointAddr

DialableAddr returns this endpoint's id paired with its bound local address, a dialable seed a peer can bootstrap from without discovery.

func (*Endpoint) Endpoint

func (e *Endpoint) Endpoint() *iroh.Endpoint

Endpoint exposes the underlying go-iroh endpoint for the blob and manifest layers, which need it to open blob streams. It is the one deliberate seam leak, scoped to this module's own sub-packages; application code stays on the fenced Conn/Discovery surface.

func (*Endpoint) ID

func (e *Endpoint) ID() key.EndpointID

ID returns the bound endpoint's identity key.

func (*Endpoint) LocalAddr added in v0.1.1

func (e *Endpoint) LocalAddr() netip.AddrPort

LocalAddr returns the endpoint's bound UDP address. Combined with Endpoint.ID via netaddr.NewEndpointAddr(id).WithIP(localAddr), it forms a dialable seed address for a peer on a known host — the loopback and seed-list case.

func (*Endpoint) LocalTicket

func (e *Endpoint) LocalTicket() string

LocalTicket returns the ticket for this endpoint at its actual bound local address, so a node can publish its dial address without the caller knowing the resolved port (the ephemeral-bind case).

func (*Endpoint) LookupServices

func (e *Endpoint) LookupServices() *iroh.AddressLookupServices

LookupServices returns the endpoint's address-lookup registry, so a caller can add a pkarr, DNS, or in-memory resolver beyond the gossip discovery that NewGossipDiscovery registers. Resolvers added here back Endpoint.ResolveAddr and Endpoint.ConnectID.

func (*Endpoint) ResolveAddr

func (e *Endpoint) ResolveAddr(ctx context.Context, id key.EndpointID) (netaddr.EndpointAddr, error)

ResolveAddr resolves a bare endpoint id to a dialable address through the endpoint's address-lookup services (gossip discovery, and any pkarr or DNS resolver the caller added). It returns the first result whose address is non-empty, or an error if no service yields one. It exists because go-iroh's Connect consults lookup services only after a connection is established, so a peer known only by id cannot be dialed without resolving first.

func (*Endpoint) Serve

func (e *Endpoint) Serve(appALPN string, h Handler, disc Discovery) error

Serve starts the iroh.Router on this endpoint: it registers appALPN to h and, when disc carries a gossip handler, gossip.ALPN to it — one router serving both ALPNs. disc may be nil for an endpoint with no discovery. Serve returns once the accept loop is running in the background; it does not block. It must be called at most once, on an endpoint bound without ALPNs. After Serve, Endpoint.Close shuts the router (and the endpoint) down.

func (*Endpoint) Subscribe added in v0.1.1

func (e *Endpoint) Subscribe(ctx context.Context, topic gossip.TopicID, bootstrap []netaddr.EndpointAddr) (*gossip.Topic, error)

Subscribe joins the gossip topic and returns it for signed publish/receive via PublishSigned and VerifiedEnvelopes. bootstrap seeds the swarm. The topic shares the endpoint's gossip instance, so Endpoint.Serve must register the gossip ALPN (it does whenever a gossip Discovery or any Subscribe is in use). The caller closes the returned topic.

func (*Endpoint) Ticket

func (e *Endpoint) Ticket(addr netip.AddrPort) string

Ticket returns an endpointticket-encoded address for this endpoint at addr, so a connector can dial it without discovery. Used to point one endpoint at another on a known address (e.g. a loopback test or a seed list).

type Handler

type Handler func(ctx context.Context, conn *Conn) error

Handler runs one accepted connection to completion. The endpoint wraps the go-iroh connection as a *Conn before calling it, so a handler never sees go-iroh. ctx is cancelled when the endpoint shuts down. The handler owns the connection lifetime and must Close it; a returned error closes only that connection and is not fatal to the accept loop.

type NodeKey

type NodeKey struct {
	// contains filtered or unexported fields
}

NodeKey is a node's long-lived ed25519 identity. Its public key is the node's id on the mesh — the same key the go-iroh endpoint binds with (so the wire EndpointID equals the node id) and the key a signed receipt or heartbeat verifies against.

NodeKey exposes a generic NodeKey.Sign for callers that just need a signature, and the raw ed25519 key via NodeKey.Ed25519/NodeKey.Public for callers that own their payload framing (e.g. a domain-separated receipt signer). The zero value is unusable; construct with GenerateNodeKey or LoadOrCreate.

Example

ExampleNodeKey signs a message and verifies it against the node's public key.

package main

import (
	"crypto/ed25519"
	"fmt"

	irohmesh "github.com/tmc/mlx-go-iroh"
)

func main() {
	seed := make([]byte, ed25519.SeedSize) // all-zero seed, for a deterministic example
	k, err := irohmesh.NodeKeyFromSeed(seed)
	if err != nil {
		fmt.Println(err)
		return
	}

	msg := []byte("hello mesh")
	sig := k.Sign(msg)
	fmt.Println(irohmesh.Verify(k.Public(), msg, sig))
}
Output:
true

func GenerateNodeKey

func GenerateNodeKey() (NodeKey, error)

GenerateNodeKey returns a fresh random NodeKey.

func LoadOrCreate

func LoadOrCreate(path string) (NodeKey, error)

LoadOrCreate loads the NodeKey whose seed is stored at path, creating and persisting a fresh one if the file does not exist. The seed is written as hex with 0600 permissions; intermediate directories are created. This is the stable-identity path: a node calls it once at startup with a path under its home directory.

func NodeKeyFromEd25519

func NodeKeyFromEd25519(priv ed25519.PrivateKey) (NodeKey, error)

NodeKeyFromEd25519 wraps an existing ed25519 private key, so a caller that already manages an identity key can present it on the mesh.

func NodeKeyFromSeed

func NodeKeyFromSeed(seed []byte) (NodeKey, error)

NodeKeyFromSeed returns the NodeKey deterministically derived from seed, which must be ed25519.SeedSize bytes.

func (NodeKey) Ed25519

func (k NodeKey) Ed25519() ed25519.PrivateKey

Ed25519 returns the underlying ed25519 private key, for callers that sign their own domain-separated payloads (e.g. a receipt signer that owns its framing). The returned key aliases the NodeKey's material; do not mutate it.

func (NodeKey) ID

func (k NodeKey) ID() key.EndpointID

ID returns the node's go-iroh endpoint id, equal to its ed25519 public key.

func (NodeKey) Public

func (k NodeKey) Public() ed25519.PublicKey

Public returns the node's ed25519 public key.

func (NodeKey) Sign

func (k NodeKey) Sign(msg []byte) []byte

Sign returns the ed25519 signature of msg under the node key. It is the generic signer; callers that need domain separation should prefix msg themselves or sign through NodeKey.Ed25519.

type SignedEnvelope

type SignedEnvelope struct {
	NodeID  []byte `json:"node_id"` // ed25519 public key of the signer
	Payload []byte `json:"payload"` // opaque application bytes
	Sig     []byte `json:"sig"`     // signature over envelopeDomain||Payload
}

SignedEnvelope is a gossip message carrying an application payload signed by the sending node. The shared layer owns only the envelope (identity + signature + size bound); Payload is OPAQUE bytes the application serializes however it likes — JSON for new payloads, or a domain-tagged binary form for a payload whose signature must be reproducible across implementations. The envelope never inspects or re-serializes Payload, so no domain type leaks into this module and a binary signed payload survives byte-for-byte.

The wire form is the JSON encoding of this struct; NodeID, Payload, and Sig are base64 by encoding/json's []byte handling. (The JSON is the outer transport framing only — it does not touch the bytes the signature covers.)

func SignEnvelope

func SignEnvelope(k NodeKey, payload []byte) (SignedEnvelope, error)

SignEnvelope wraps payload in an envelope signed by k. payload is treated as opaque bytes: it is signed and carried verbatim, never re-serialized. It returns an error if the resulting wire message would exceed MaxGossipFrame, so an oversized control message fails at the sender rather than being silently dropped by the gossip layer.

Example
package main

import (
	"fmt"

	irohmesh "github.com/tmc/mlx-go-iroh"
)

func main() {
	k, err := irohmesh.GenerateNodeKey()
	if err != nil {
		panic(err)
	}
	env, err := irohmesh.SignEnvelope(k, []byte("heartbeat"))
	if err != nil {
		panic(err)
	}
	fmt.Println(env.Verify() == nil)
	fmt.Printf("%s\n", env.Payload)
}
Output:
true
heartbeat

func (SignedEnvelope) Verify

func (e SignedEnvelope) Verify() error

Verify reports whether the envelope is well formed and its signature is valid for NodeID over the payload. A verified envelope means the bytes in Payload were signed by the holder of NodeID's key; it says nothing about the contents.

Directories

Path Synopsis
Package blob is a content-addressed byte store over go-iroh blobs: hash some bytes to get a Hash, serve them to peers, and pull them back BAO-verified so a corrupt or hostile peer cannot substitute different bytes.
Package blob is a content-addressed byte store over go-iroh blobs: hash some bytes to get a Hash, serve them to peers, and pull them back BAO-verified so a corrupt or hostile peer cannot substitute different bytes.
Package manifest pulls a content-addressed set of blobs from peers over go-iroh blobs, falling back to a central hub when no peer can serve one.
Package manifest pulls a content-addressed set of blobs from peers over go-iroh blobs, falling back to a central hub when no peer can serve one.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL