nodenet

module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jun 10, 2026 License: MIT

README

nodenet

A reusable core for decentralized P2P connectivity. nodenet is a Go library that finds a route to any node by its identifier and helps two peers establish a direct, end-to-end connection - even when both sit behind NAT. It is the transport substrate, not the application: nodenet gets your bytes to the right peer and opens the pipe; what flows through that pipe is entirely up to you.

Think of it the way a blockchain is to a dApp. A blockchain provides consensus and state; the dApps decide what to do with it. nodenet provides decentralized addressing, routing and NAT traversal; the apps on top - a messenger, a file-sync tool, a CRDT backend, a signaling plane for media - decide what to do with the connections it gives them.

Status: pre-release, under active development. The public API (github.com/udisondev/nodenet/node) is stabilizing but may still change.


The core idea

Most P2P overlays lean on public nodes to carry routing and relaying; nodes behind NAT are second-class - they can reach the network but the network can't route through them. nodenet's defining decision is the opposite:

Routing and forwarding are spread across all nodes, not just the public ones.

A NAT node dials outbound and keeps that channel bidirectional. The overlay forwards over that same live edge, so a phone behind a carrier-grade NAT becomes a full router that carries other peers' traffic - not a leaf that only consumes. Connectivity, not the routing algorithm, is what's hard in a permissionless overlay, and that's where nodenet puts its design effort.

What you get
  • Address by identity, not by location. A node is its NodeID (a 256-bit BLAKE2b hash of its public key). You route to an ID; the overlay converges to it hop by hop. No DNS, no trackers, no central registry.
  • NAT traversal built in. Reflexive-address learning -> DCUtR-style hole-punching -> packet relay as a last resort, tried cheapest-first. Most peers end up with a direct, end-to-end path.
  • End-to-end encryption to a public key you can verify. Rendezvous returns the target's keys authenticated against its NodeID - a forwarder on the path cannot impersonate the destination (anti-MITM). Bulk traffic runs directly between endpoints; the multi-hop overlay only carries small control and rendezvous frames.
  • Permissionless, with teeth. No gatekeeper decides who joins. Sybil and Eclipse resistance comes from layered, locally-verifiable invariants: proof-of-work on every identity, signed routing messages, per-originator rate limits on work-generating queries (keyed to the signed originator, so a flood can't dodge them by spreading across edges), replay protection on handshakes, subnet diversity caps, k-bucket eviction rules, and per-IP / global connection limits.
  • Zero-copy hot path. A transit frame travels onward in the very buffer it arrived in - only the TTL byte is patched in place. The routing core targets 0 allocs/op.
What it is not
  • Not a blockchain, ledger, or consensus system - there is no global agreed state.
  • Not a storage / DHT-value layer - the DHT is used for routing to nodes, not storing arbitrary values.
  • Not an application protocol - it carries your bytes; it does not define your messages.
  • Not a bulk-data overlay - large transfers go directly peer-to-peer over the connection nodenet opens for you, never multi-hop through the overlay.

How it compares

There are many ways to move bytes between peers. nodenet occupies a specific niche: a structured, identity-addressed overlay where every node - including NAT nodes - routes, exposed as an embeddable Go library.

nodenet libp2p Tor / I2P WebRTC Tailscale / WireGuard Hyperswarm (DAT)
Shape Embeddable Go library Modular P2P stack Anonymity network Browser RTC + signaling Mesh VPN DHT + connector
Addressing NodeID (hash of key) PeerID (hash of key) Onion / dest address Out-of-band (SDP) IP within tenant Topic / key
Decentralized routing Yes, all nodes DHT, public relays favored Volunteer relays No (needs signaling server) No (coordination plane) DHT lookup only
NAT nodes route for others Yes (by design) Limited (relays/public) No (clients are leaves) No No No
NAT traversal Reflexive + hole-punch + relay Hole-punch (DCUtR) + relay N/A (uses relays) ICE/STUN/TURN DERP relays + direct UDP hole-punch
Anonymity No (not a goal) No Yes No No No
Trust model Permissionless + PoW/Sybil defenses Permissionless (app-defined) Permissionless App-defined Central control plane Permissionless
E2E crypto Built in (TLS + sealed-box) Built in (Noise/TLS) Built in DTLS/SRTP WireGuard Noise
Best for Decentralized apps needing every node to route General P2P apps Censorship resistance Real-time media in browsers Private device meshes P2P file/data apps

When to reach for nodenet: you're building a decentralized app - a messenger, a collaborative editor, a sync engine - that needs to find a peer by identity and open a direct encrypted channel, with no central server for signaling or relay, and you want NAT nodes to pull their weight as routers. If you need a browser runtime, anonymity, or a managed control plane, the alternatives above fit better.


Example: a minimal P2P messenger

nodenet handles discovery, routing and NAT traversal; the messenger handles messages. The flow is: find the peer by NodeID -> open a direct edge -> send your own bytes over it.

package main

import (
	"context"
	"crypto/rand"
	"log"

	"github.com/udisondev/nodenet/node"
	"github.com/udisondev/nodenet/pow"
	"github.com/udisondev/nodenet/routing"
	"github.com/udisondev/nodenet/transport"
	"github.com/udisondev/nodenet/transport/quic"
)

const powDifficulty = 16 // network-wide constant: leading-zero bits required of a NodeID

func main() {
	ctx := context.Background()

	// 1. Identity. Mint one that clears the network's proof-of-work, then persist
	//    id.Seed() (the only secret) so the node keeps the same NodeID across restarts.
	id, err := pow.Solve(ctx, rand.Reader, powDifficulty)
	if err != nil {
		log.Fatal(err)
	}
	// On restart instead: id := identity.FromSeed(savedSeed)

	// 2. Transport. A QUIC socket with mutual-TLS authentication to NodeID, plus
	//    inbound caps as a DoS backstop on a public entry point.
	tr, err := quic.Listen(id, ":4242",
		quic.WithMaxInbound(256),
		quic.WithMaxInboundPerIP(32),
	)
	if err != nil {
		log.Fatal(err)
	}
	defer tr.Close()

	// 3. Node. The runtime: identity + routing tables + transport + dispatch loop.
	n := node.New(id, tr,
		node.WithDmin(powDifficulty), // enforce origination-PoW on every received packet
		node.WithRelay(),             // volunteer to relay for peers that can't hole-punch
	)

	// 4. Bootstrap off at least one known contact and start the loops. Tag the
	//    entry point PublicAnchor: it is a stable, directly-dialable peer the
	//    connectivity floor leans on as a re-dial anchor.
	n.Bootstrap([]routing.Contact{{
		ID:    bootstrapID,
		Caps:  routing.PublicAnchor,
		Addrs: []transport.Addr{{Net: "quic", Endpoint: "bootstrap.example:4242"}},
	}})
	go func() {
		if err := n.Run(ctx); err != nil {
			log.Printf("node stopped: %v", err)
		}
	}()

	// 5a. Receive: messages addressed to this node's ID surface on Deliveries().
	go func() {
		for msg := range n.Deliveries() {
			log.Printf("from %s: %s", msg.Originator, msg.Payload)
		}
	}()

	// 5b. Send a small message routed to a peer by NodeID (multi-hop, no direct link).
	//     Origination never blocks, so Send takes no context.
	if err := n.Send(peerID, []byte("hi")); err != nil {
		log.Printf("send: %v", err)
	}

	// 5c. Or open a DIRECT end-to-end edge for a real conversation / bulk data.
	//     Connect discovers + verifies the peer's coordinates (rendezvous), then
	//     dials directly or hole-punches through NAT.
	conn, err := n.Connect(ctx, peerID)
	if err != nil {
		log.Printf("connect: %v", err)
		return
	}
	defer conn.Close()
	// conn is an authenticated, bidirectional channel: your messenger speaks its own
	// protocol over it from here.
}

var bootstrapID, peerID node.ID

Three ways to talk to a peer, by intent:

  • Send(target, payload) - fire a small message routed multi-hop to a NodeID. No prior connection needed; the overlay converges to the target. Good for control, presence, offline-ish nudges. It never blocks on the network, hence no context.
  • SendDirect(ctx, target, payload) - send an application payload over a direct edge, dialing or hole-punching one via Connect if none is up. The bytes never transit other nodes; on the remote side the payload surfaces on Deliveries(), same as Send.
  • Connect(ctx, target) - establish a direct, authenticated edge (rendezvous -> hole-punch/relay) and speak your own protocol over it. Good for a live conversation or bulk transfer that should not traverse the overlay.

For connectionless end-to-end content you can also use the sealed-box path (Rendezvous + the rendezvous package): sender-authenticated encryption to the recipient's static X25519 key (ephemeral-static ECDH + AEAD + a sender signature), carried over the overlay, where forwarders see only ciphertext.


Try it: the bundled node binary

The repository ships a runnable node, cmd/nodenet - a thin wiring of the library (identity + transport/quic + node) and a worked example of how the pieces compose. On first run it mints an identity that clears the network's proof-of-work and persists the seed (the only secret) to a file - by default under the user config directory, or wherever -seed points - so the NodeID stays stable across restarts.

Start a first node; it prints its NodeID on startup:

go run ./cmd/nodenet -addr :4242 -pow 16
# logs "nodenet: node 0000ab12..." - others bootstrap from this ID

Join it from a second terminal or machine (a separate seed file = a separate identity):

go run ./cmd/nodenet -seed peer.seed -addr :4243 -pow 16 \
    -bootstrap <nodeid-hex>@host:4242

Bootstrap entries are <nodeid-hex>@host:port, comma-separated - the NodeID is required because peers are authenticated, not merely dialed: the QUIC handshake verifies the peer's certificate against the NodeID you expect, so a bare address is not enough. At least one of -bootstrap (a way into an existing overlay) or -addr (a door for others to dial in) must be set; usually both. Other flags: -relay volunteers the node as a relay for peers that cannot hole-punch; -max-inbound / -max-inbound-per-ip are the inbound DoS backstops.


Architecture

nodenet is a set of strictly single-purpose packages forming an acyclic dependency DAG. Each has a detailed package-doc in its <pkg>.go - read that first; it explains the package's role and reasoning. Bottom-up:

Package Role
kad Kademlia keyspace: the ID type (256-bit NodeID) and XOR-metric math. Pure, imports only stdlib - the root leaf.
identity Pure crypto. One master-seed -> HKDF -> independent Ed25519 (signing identity) + static X25519 (e2e key exchange). Only the seed is persisted.
wire Byte codec for frames (version | type | len | payload). The "anti-bytes.Buffer": owns no memory, never grows, zero-copy on read, never panics on malformed input.
pow Proof-of-work gate on the NodeID. Minting an identity is expensive (grind ~2^d seeds); verifying is one glance at the high bits. Prices mass Sybil identity creation.
transport The one polymorphic boundary. Authenticates peers to their NodeID; opens/accepts bidirectional edges; surfaces all inbound frames on a single channel. Packet is a pooled, borrow-on-send buffer (zero-copy forwarding).
transport/mem In-memory transport for deterministic tests (shared Hub, with partition/heal primitives).
transport/quic Production transport: QUIC + mutual-TLS over one shared UDP socket (4-tuple reuse for hole-punching), with inbound DoS caps.
routing The two overlay tables (soft-state k-bucket knowledge vs actively-maintained live edges) and the pure logic beside them: routing-message codec, greedy Decide, control protocol (ping/pong/lookup/neighbors/siblings/leave). No I/O.
rendezvous Addressing & discovery: signed routed handshake (verify keys against NodeID, anti-MITM) and sealed-box e2e encryption. Sits beside routing, not on top.
nat NAT-traversal logic and codecs: reflexive learning, hole-punching, packet relay. Pure logic; the socket I/O lives in transport/quic.
node Top of the DAG and the public API. Composes everything: the single dispatch loop (recursive greedy forwarding), origination, the control protocol, the churn-maintenance loop, and the NAT orchestration.
Cross-cutting models worth knowing
  • Packet ownership (zero-alloc). A Packet is a pooled buffer with one lifecycle: transport.Get() -> fill -> Send (which borrows: copies synchronously, never takes ownership) or hand up -> Release() exactly once. Borrowing is what makes zero-copy forwarding, free local-repair retries, and one-buffer disjoint-path fan-out possible. Build with -tags transportdebug to make double-Release / use-after-Release panic instead of silently corrupting the pool.
  • Two tables, two recovery models. Knowledge (k-buckets) heals lazily - last-seen tracking, periodic refresh of stale buckets, eviction probes that let live newcomers displace dead incumbents, opportunistic learning. Live edges are maintained actively - a dropped sibling is replaced at once, re-running admission-PoW so churn never opens a backdoor around the Sybil defenses. A hard connectivity floor (KMin = 3) keeps every node above zero connectivity.
  • Three tiers of rules (the security foundation). In a permissionless network you cannot assume a peer runs your code. (1) Protocol consensus - disagree and you're simply not on the same network. (2) Verifiable invariants - PoW, signatures, TTL clamps, format validity, rate limits - checked locally on every interaction, trusting no peer. (3) Local policy - topology numbers, honor-system. Security never rests on tier 3. Code comments tag which tier an invariant belongs to.

Requirements

  • Go 1.26+ (uses stdlib crypto/hkdf, crypto/ecdh X25519, testing/synctest).
  • Two direct external dependencies: golang.org/x/crypto (BLAKE2b, ChaCha20-Poly1305) and, for the production transport, github.com/quic-go/quic-go.
  • No Makefile, no CI scaffolding - everything runs through the go toolchain.

Building and testing

go build ./...                               # build
go vet ./...                                 # static analysis
go test ./...                                # all tests
go test ./kad -run TestCommonPrefixLen -v    # one test in a package
go test ./... -run '^$' -bench . -benchmem   # all benchmarks, with allocation counts
go test ./kad -fuzz FuzzParseID              # fuzz one target (one per package at a time)
go test -tags transportdebug ./transport/... # enable Packet lifecycle assertions
Development discipline
  • TDD + benchmarks from the start. Every unit: red -> green -> refactor. Every hot path gets a Benchmark* immediately; the goal is 0 allocs/op, regressions caught with -benchmem.
  • Fuzz every decoder of untrusted input. Any parser of external bytes (wire frames, routing messages, addresses, sealed-box, the X.509 extension) must have a Fuzz* target: it must not panic on arbitrary input and must round-trip / hold its invariant on success.
  • Determinism in tests. In-memory transport + fake clock + testing/synctest; no real time or network in ordinary tests. The real QUIC layer is exercised behind build tags.

License

MIT - see LICENSE.

Directories

Path Synopsis
cmd
nodenet command
Command nodenet runs a single overlay node over the QUIC transport: it loads (or mints) a persistent identity, binds a socket, seeds the knowledge table from the given bootstrap peers, and runs the dispatch and maintenance loops until interrupted.
Command nodenet runs a single overlay node over the QUIC transport: it loads (or mints) a persistent identity, binds a socket, seeds the knowledge table from the given bootstrap peers, and runs the dispatch and maintenance loops until interrupted.
Package identity holds the cryptographic identity of a nodenet node: a single random master-seed from which everything else is derived deterministically.
Package identity holds the cryptographic identity of a nodenet node: a single random master-seed from which everything else is derived deterministically.
Package kad holds the Kademlia keyspace primitives of nodenet: the node identifier type ID and the XOR-metric arithmetic built on it (distance, common-prefix length).
Package kad holds the Kademlia keyspace primitives of nodenet: the node identifier type ID and the XOR-metric arithmetic built on it (distance, common-prefix length).
Package nat is the NAT-traversal layer: it gives a node behind NAT direct connectivity to peers instead of only outbound reachability to public ones, so that NAT nodes carry routing and forwarding like everyone else (the whole point of the overlay).
Package nat is the NAT-traversal layer: it gives a node behind NAT direct connectivity to peers instead of only outbound reachability to public ones, so that NAT nodes carry routing and forwarding like everyone else (the whole point of the overlay).
Package node is the runtime that composes nodenet into a working overlay member.
Package node is the runtime that composes nodenet into a working overlay member.
Package pow is the proof-of-work gate on the NodeID: an asymmetric cost that makes minting an identity expensive while keeping verification almost free.
Package pow is the proof-of-work gate on the NodeID: an asymmetric cost that makes minting an identity expensive while keeping verification almost free.
Package rendezvous is the addressing-and-discovery layer of nodenet: how a node finds another node's keys and coordinates by its NodeID, and how a node sends a small piece of end-to-end content over the overlay without a direct connection.
Package rendezvous is the addressing-and-discovery layer of nodenet: how a node finds another node's keys and coordinates by its NodeID, and how a node sends a small piece of end-to-end content over the overlay without a direct connection.
Package routing holds the two overlay tables of nodenet and nothing that acts on them.
Package routing holds the two overlay tables of nodenet and nothing that acts on them.
Package transport is the pipe of nodenet: it delivers bytes between nodes, authenticated to a NodeID, and nothing else.
Package transport is the pipe of nodenet: it delivers bytes between nodes, authenticated to a NodeID, and nothing else.
mem
Package mem is the in-memory transport: a deterministic, channel-based implementation of transport.Transport for tests.
Package mem is the in-memory transport: a deterministic, channel-based implementation of transport.Transport for tests.
quic
Package quic is the production transport: QUIC (over UDP) with mutual TLS 1.3, authenticating each peer to its NodeID.
Package quic is the production transport: QUIC (over UDP) with mutual TLS 1.3, authenticating each peer to its NodeID.
quic/nattest
Package nattest is an in-process NAT emulator for deterministic hole-punching and relay tests.
Package nattest is an in-process NAT emulator for deterministic hole-punching and relay tests.
transporttest
Package transporttest is a shared contract suite for transport.Transport implementations.
Package transporttest is a shared contract suite for transport.Transport implementations.
Package wire is the byte-layout contract of nodenet: it turns protocol values into bytes and back, and nothing else.
Package wire is the byte-layout contract of nodenet: it turns protocol values into bytes and back, and nothing else.

Jump to

Keyboard shortcuts

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