This document explains the entire system end to end: what it is, the technologies it's built on, how every subsystem works, and how they all connect. It's written for a developer who needs to understand the whole system before changing anything in it.
dp2ptcs is a peer-to-peer networking library written in Go. Each running instance is a node. Nodes:
- have a long-term cryptographic identity (an Ed25519 keypair, encrypted at rest),
- find each other over a Kademlia DHT (no central server, no directory),
- connect to each other directly over QUIC, punching through NAT with STUN and falling back to TURN relays when direct connectivity isn't possible,
- encrypt every application message end-to-end with a Double Ratchet (the same cryptographic construction used by Signal), layered on top of QUIC's own transport-level TLS 1.3 encryption.
In short: it's a Signal-style E2E-encrypted messaging layer, running over a self-organizing Kademlia network, transported over QUIC, with STUN/TURN NAT traversal — all as a single Go module with no external services required except an optional TURN server.
The pkg/api.Node interface is the entire public surface: Start, Stop, Send, Receive, ID, NATStatus. Everything else is internal implementation.
| Concern | Technology | Where |
|---|---|---|
| Language / runtime | Go | whole repo |
| Transport protocol | QUIC (github.com/quic-go/quic-go) |
internal/transport |
| Transport security | TLS 1.3, self-signed certs bound to Ed25519 identity | internal/transport/tls.go |
| Application-layer identity | Ed25519 (crypto/ed25519) |
internal/crypto/identity.go |
| Application-layer E2E key exchange | X3DH (Extended Triple Diffie-Hellman, X25519) | internal/crypto/x3dh.go |
| Application-layer E2E encryption | Double Ratchet (Signal Protocol), HKDF-SHA256, ChaCha20-Poly1305 | internal/crypto/double_ratched.go, kdf_chain.go, root_chain.go |
| Identity-at-rest encryption | Argon2id (KDF) + AES-256-GCM | internal/crypto/keystore.go |
| Identity file permission enforcement | POSIX file mode 0600 (Unix) / Win32 DACL via raw syscalls (Windows) | internal/crypto/file_store_unix.go, file_store_windows.go |
| Wire framing | Protocol Buffers (google.golang.org/protobuf) + varint length prefix |
internal/messaging |
| Peer discovery / routing | Kademlia DHT (XOR distance, k-buckets, iterative lookup) | internal/dht |
| Multiplexing STUN + QUIC over one UDP socket | Custom demuxer, first-byte packet classification | internal/network/mux |
| NAT traversal (basic) | STUN (github.com/pion/stun), TURN (github.com/pion/turn/v2) |
internal/network/nat |
| NAT traversal (advanced, not yet wired in) | ICE-style candidate pairing with signed candidate records | internal/transport/nat |
| Rate limiting | Token bucket, per source IP | internal/usecase/rate_limiter.go |
The codebase follows a rough Clean Architecture layering. Lower layers know nothing about higher layers:
pkg/api — public interfaces (Node, Message, NATStatus). No implementation.
pkg/node — the concrete Node implementation ("tacticalNode"). Wires everything together.
internal/usecase — application services: ConnectionManager, DiscoveryManager, NodeServer,
SessionManager, IdentityManager, rate limiting.
internal/domain — pure entities and interfaces: Peer, Discoverer, CandidateRecord, validation.
No dependencies on crypto/network/transport.
internal/dht — Kademlia: routing table, XOR distance, iterative lookup, the DHT wire RPC.
internal/handshake — the connection-time X3DH handshake protocol (turns a raw stream into
a SecureSession).
internal/messaging — wire format: Message struct, protobuf framing, handshake byte framing.
internal/transport — QUIC transport adapter + mTLS config + the ICE-style NAT subsystem.
internal/network — the UDP demuxer (STUN vs QUIC) + STUN/TURN clients + legacy discoverer.
internal/crypto — all cryptographic primitives: identity, X3DH, Double Ratchet, keystore,
per-OS secure file storage.
cmd/node — the CLI entry point (`main.go`) that exercises the whole stack.
Dependency direction is strictly downward: pkg/node depends on usecase, which depends on domain/dht/crypto/transport/messaging, which depend on nothing above themselves. internal/crypto and internal/domain have no dependencies on any other internal package — they're the foundation.
Construction is synchronous and does no networking. In order:
- Applies
Optionfunctions toDefaultConfig()(port 9000, 5s STUN/Bootstrap timeouts, 10s TURN timeout, 24h prekey rotation). - Falls back to the
DP2PTCS_PASSPHRASEenvironment variable if no passphrase was set via options. - Validates config (
Config.Validate()): port range, passphrase strength (viacrypto.ValidatePassphrase), non-empty key path, positive timeouts. - Loads or creates the node's identity (
usecase.IdentityManager.LoadOrCreate) — see §5. - Creates the in-memory session manager (§7.5) and the X3DH handshaker (§6).
- Generates a self-signed mTLS
tls.Configbound to the node's Ed25519 key (§8.2) and constructs theQUICTransport. - Builds the Kademlia
RoutingTable(k=20) andDHTService, and aConnectionManager(§9.1) wired to it. The routing table's stale-peer liveness check (SetPingFunc) is wired toConnectionManager.Ping. - Builds the initial locally-advertised address list from
cfg.PublicAddr/cfg.ListenAddr(skipping wildcard binds like0.0.0.0:port), and constructsdht.NetworkRPCClientwith it. - Builds
usecase.DiscoveryManager(the real, production discoverer — see §10.2) and wires it into theConnectionManager. - Builds the ICE-style candidate signaling service and the (currently unused in the main flow)
HolePunchCoordinator/ConnectivityCoordinator— see §11.3. - Builds the
appRouter(§9.4) andNodeServer(§9.3).
Nothing is listening on a socket yet. No network I/O has happened.
This is where everything comes alive, strictly in this order (order matters — see the STUN/QUIC ordering note in §12.2):
- Guards against double-start / start-after-stop under
lifecycleMu. - Binds the UDP socket (
net.ListenConfig{Control: setExclusiveAddrUse}.ListenPacket) —setExclusiveAddrUseis a Windows-onlySO_EXCLUSIVEADDRUSEhardening no-op'd on Unix (§13). - Wraps that socket in
mux.NewDemuxer(§12) — from this point on, the demuxer'sreadLoop()goroutine owns all reads from the real socket. - Builds the initial NAT-traversal candidate record set (
refreshCandidateRecords, §11.3). - Starts the background prekey-rotation goroutine (§6.4).
- If STUN servers are configured: runs one synchronous STUN pass (§10.1) — this must happen before QUIC starts listening, because STUN needs the socket's reads via the demuxer's
STUNConn()and this ordering avoids races where QUIC's listener isn't draining its own queue yet. A 15-minute background ticker then keeps refreshing it. - If TURN servers are configured: allocates a relay (§10.3) and stands up a second, fully independent QUIC listener on the relay connection.
- Starts QUIC listening on the demuxer's
QUICConn()(the main listener) vian.transport.ListenOnConn(...), then startsNodeServer.StartWithListenerin a background goroutine (§9.3). - For each configured bootstrap node, calls
DiscoveryManager.Bootstrap(§10.2) — failures are logged, not fatal (a node can run in isolated mode). - Spawns a watcher goroutine that calls internal
stop()when the context is cancelled.
Idempotent via sync.Once. Cancels the run context, closes the demuxer (which closes the real socket and both virtual conns), closes any TURN listeners, waits for all background workers, then closes the incoming channel.
- Identity (
internal/crypto/identity.go): an Ed25519 keypair.NodeID = SHA256(PublicKey)— a 32-byte value used everywhere as the canonical peer identifier (DHT keys, certificate pinning, session map keys). It is derived, not a separate secret. - At-rest encryption (
keystore.go): the private key is encrypted with AES-256-GCM, where the AES key is derived from the user's passphrase via Argon2id (128 MB memory cost, time cost 3, 4 threads — deliberately expensive to slow brute-force).ValidatePassphraserequires ≥12 characters and at least 3 of {upper, lower, digit, special}. - File storage (
file_store.go+ per-OS files):FileIdentityStoreimplementsIdentityStore{Save, Load}. Saving writes to a temp file then does an atomic rename. Loading first callsverifyFileSecure(path)before reading the file — this is a defense-in-depth check independent of the AES-GCM authentication:- Unix (
file_store_unix.go): requires file mode exactly0600. - Windows (
file_store_windows.go): uses raw Win32 syscalls (SetEntriesInAclW,SetNamedSecurityInfo) to write a DACL granting access to the current user only, with inheritance explicitly disabled (PROTECTED_DACL).verifyCurrentUserOnlyACL(used by both productionLoad()and the test suite) checks the DACL is protected, contains exactly one ACE, and that ACE's SID matches the current user — this was fixed during development after CI caught that the originalverifyFileSecureonly checked file existence, not permissions, meaning a loosened ACL would silently be accepted at load time. See §14.6.
- Unix (
IdentityManager.LoadOrCreate(usecase/identity_manager.go): triesLoad; onErrNoIdentityFoundgenerates a fresh identity and persists it; any other error is propagated.
internal/handshake/handshake.go. This runs once per QUIC connection (not once per node — every time two peers establish a new connection, they redo this) and produces a crypto.SecureSession (the Double Ratchet, §7).
Each Handshaker holds, in addition to the node's long-term Ed25519 identity:
- a long-term X25519 identity key (
localIdentityXPriv/Pub) — a separate DH-capable key, distinct from the Ed25519 signing key, - a signed prekey (X25519), rotated periodically (§6.4).
Both Initiate (dialer) and Respond (accepter) send a messaging.HandshakeExchange (§8.1): their Ed25519 identity pub, X25519 identity pub, current prekey pub, a fresh ephemeral X25519 keypair generated per-connection, an Ed25519 signature over all four, and a 5-minute expiry. Each side verifies the other's signature and expiry before proceeding.
Using the standard X3DH triple-DH construction (crypto/x3dh.go):
DH1 = IK_initiator × SPK_responder
DH2 = EK_initiator × IK_responder
DH3 = EK_initiator × SPK_responder
rootKey = HKDF-SHA256(0xFF*32 || DH1 || DH2 || DH3, info="Tactical-X3DH-Root-v1")
InitiateX3DH/RespondX3DH compute the same three DH products with operands swapped appropriately so both sides derive an identical rootKey, without ever transmitting it.
That rootKey seeds a brand-new crypto.DoubleRatchetSession (§7), keyed by the two ephemeral public keys exchanged in this handshake.
Handshaker.StartPrekeyRotation(ctx, interval) runs a ticker (default: cfg.Timeouts.PrekeyRotationInterval, 24h) that regenerates the X25519 prekey under a mutex. currentPrekey() takes a defensive copy so an in-flight handshake can't observe a torn/partially-rotated bundle.
internal/crypto/double_ratched.go, kdf_chain.go, root_chain.go, session.go.
type SecureSession interface {
Encrypt(plainText []byte) (cipherText, dhPublicKey []byte, messageNumber uint64, previousChainLength uint32, err error)
Decrypt(cipherText, remoteDHPubKey []byte, messageNumber uint64, previousChainLength uint32) (plainText []byte, err error)
}DoubleRatchetSession is the production implementation. (SymmetricSession in secure_session.go also implements this interface but is a simpler two-fixed-chain variant used only in its own unit test — the live system always uses DoubleRatchetSession.)
- Root chain (
RootChain.Step): mixed forward with fresh DH shared secrets whenever the remote party's DH public key changes (i.e., on every new "epoch"). HKDF-SHA256 with the current root key as HKDF salt and the DH secret as the HKDF secret, producing a new root key + a new symmetric chain key. This is what gives post-compromise security — even if a chain key is ever exposed, the next DH ratchet step heals the session. - Symmetric chains (
KDFChain.Ratchet, one for sending, one for receiving): each call is a one-way HKDF step that destroys the old chain key and produces a fresh 32-byte message key. This is what gives forward secrecy — past message keys can't be recomputed from a later chain state. - Initial chain assignment on session creation compares the two ephemeral public keys lexicographically (
bytes.Compare) so both peers deterministically agree on which of the two chains is "send" vs "receive" without needing an explicit role flag.
Each message key seals the plaintext with ChaCha20-Poly1305 (12-byte random nonce prepended to ciphertext+tag). Encrypt ratchets the send chain, increments sendMessageNumber, and returns (ciphertext, currentDHSendPub, messageNumber, previousChainLength=0).
Decrypt is the more intricate half:
- Checks a skipped-key cache first (keyed by
(remoteDHPubKeyHex, messageNumber)) in case this message arrived out of order and its key was already derived and stashed. - If the incoming
remoteDHPubKeydiffers from the currently known one, this is a new epoch (the peer just DH-ratcheted): the message number must be exactly 1 (protocol violation otherwise), any messages skipped in the old epoch are ratcheted-and-cached up topreviousChainLength, a fresh DH keypair is generated locally, both a new receive chain (from the peer's new key) and a new send chain (from the local new key) are derived viaRootChain.Step, and both message counters reset. - Replay protection: an
epochNumbersmap (keyed by hex of the sender's DH pubkey) tracks the highest message number seen per epoch; anything ≤ that is rejected outright, independent of the skipped-key cache. - Gap bounding:
MaxMessageGap = 1000— a message number that would require ratcheting more than 1000 steps forward is rejected (ErrMessageGapTooLarge) to prevent a malicious/buggy peer from forcing unbounded CPU work. - Skipped-key eviction: capped at
MaxSkippedKeys = 10000, evicted FIFO via a parallel queue slice, bounding memory even under sustained message loss. - Finally ratchets the receive chain forward to the target message number (caching each intermediate key as "skipped" along the way) and decrypts with ChaCha20-Poly1305; an authentication failure surfaces as
"message authentication failed: potential tampering or out-of-sync ratchet".
usecase/session_manager.go. Deliberately built as a single-goroutine actor: all operations (Get, Set, Use, Delete, DeleteIfCurrent) are sent as sessionRequest values over a channel to one dispatcher() goroutine, which is the only code that ever touches the map[string]*sessionMetadata. This sidesteps needing a mutex and makes "read-modify-write" style operations (like UseSession, used by Send — see §9.2) trivially race-free, since req.fn(meta.session) runs serialized inside the dispatcher itself.
- TTL eviction: sessions untouched for
SessionTTL(1h) are dropped by a 15-minute cleanup tick. DeleteSessionIfCurrent(peerID, sessionID): guards against a race where connection A's teardown accidentally deletes a session that connection B (a newer, still-live connection to the same peer) has since replaced — each session gets a unique ID atSetSessiontime, and only a delete matching the current stored ID actually removes it.
Fixed-size, no length prefix needed — HandshakeExchange.WriteTo/ReadFrom write/read exactly 32+32+32+32+64+8 = 200 bytes: four 32-byte X25519/Ed25519 keys, a 64-byte Ed25519 signature, and an 8-byte little-endian Unix timestamp (expiry).
type Message struct {
SenderID []byte // 32 bytes
Type MessageType // Packet | Command | Telemetry | DHT
DHPublicKey []byte // current ratchet epoch's DH public key
Payload []byte // Double Ratchet ciphertext
MessageNumber uint64
PreviousChainLength uint32
}ProtobufSerializer.Encode marshals this into pb.TacticalMessage (protobuf) and writes a varint length prefix followed by the encoded bytes onto the QUIC stream. Decode reads the varint, rejects anything over MaxMessageSize (1 MB hard cap — this is the guard that prevents a malicious length prefix from causing unbounded allocation), reads exactly that many bytes, unmarshals, and validates: SenderID must be 32 bytes, MessageNumber must be non-zero, PreviousChainLength capped at 10000, DHPublicKey must be 0 or 32 bytes.
Note: the ciphertext (Payload) travels inside this protobuf envelope — encryption happens at the Message level before Encode, decryption happens after Decode, both in the caller (NodeServer.handleStream, NetworkRPCClient.FindNode, tacticalNode.Send), never inside the serializer itself.
Bridges domain.Discoverer (logical peer → addresses) and transport.Transport (addresses → live connection):
ResolvePeer: delegates straight to the discoverer.ConnectToPeer→DialAddresses: races all of a peer's known addresses concurrently (transport.Dialper address), returns the first successful connection, cancels the rest via a derivedcontext.WithCancel. Every attempt and the eventual winner are logged at[DEBUG]level — this was added specifically to make NAT-traversal path selection observable (see §11).Ping: a 3-second-boundedConnectToPeer+ immediate close, used by the routing table's stale-peer liveness check (§10.3).
- Resolves
to(hex PeerID) →*domain.PeerviaDiscoveryManager.FindPeer(§10.2). getPeerConnection: checks an in-process cache (peerConnections map[string]*peerConnection, keyed by hex NodeID) underpeerMu. On a cache miss: dials viaConnectionManager.ConnectToPeer, opens one stream, runs the X3DH handshake as Initiator (§6) over that stream, stores the resulting session (SessionManager.SetSession), closes the handshake stream (it's single-purpose), caches the{conn, session}pair. A double-checked-locking pattern handles the race where two goroutines connect to the same peer simultaneously — the loser closes its redundant connection and reuses the winner's.- Opens a new stream on the (possibly cached) connection for the actual chat payload. If opening a stream on a cached connection fails (e.g., the peer dropped it), the cached entry is evicted and exactly one retry is attempted with a fresh connection.
- Encrypts the payload via
SessionManager.UseSession(so encryption is serialized through the session's owning dispatcher goroutine — see §7.5) producing(ciphertext, dhPubKey, msgNum, prevLen), wraps it in amessaging.Message{Type: TypePacket}, and writes it withProtobufSerializer.Encode.
Note: a peer connection, once established, is reused across multiple Send calls — the handshake/session-establishment cost is paid once per peer, not once per message. This is separate from NetworkRPCClient.FindNode (§10.2), which dials and hand-shakes a fresh, ephemeral connection for every single DHT RPC.
StartWithListener runs the accept loop (the production path — Start is the older direct-socket-binding variant, superseded once the demuxer was introduced but kept for standalone/testing use). Bounded by:
connSem: a semaphore cappingMaxConcurrentConnections(1000).PerIPRateLimiter(§9.5): rejects a new connection outright if the source IP is over its admission rate.
For each accepted connection (handleConnection, one goroutine per connection):
- Accepts the first stream and runs the X3DH handshake as Responder (§6), which also returns the peer's Ed25519 identity public key from the handshake payload.
- Cross-checks identity: computes
expectedNodeID = SHA256(handshakeIdentityPub)and compares it toconn.PeerNodeID()(the NodeID derived from the QUIC connection's actual TLS certificate, §8.2 of the transport section below). A mismatch is logged as[SECURITY]and the connection is dropped — this binds the cryptographic handshake identity to the transport-layer TLS identity, so a peer can't present one identity at the QUIC/TLS layer and a different one inside the encrypted handshake. - Stores the session (
SessionManager.SetSession), bounded bystreamSem(MaxStreamsPerConnection, 100), then accepts and handles further streams on the same connection concurrently, each independently callinghandleStream. handleStream: reads oneMessageper loop iteration (bounded byio.LimitedReaderatMaxMessageSize), decrypts via the session, calls the injectedMessageHandler(this isappRouter.HandleMessage, §9.4), and if a response is returned, encrypts and writes it back on the same stream.- On connection teardown, the session is deleted via
DeleteSessionIfCurrent(§7.5) so a stale session doesn't linger if the peer reconnects with a fresh handshake.
HandleMessage switches on Message.Type:
TypeDHT: unmarshals apb.FindNodeRequest, callsDHTService.HandleFindNode(§10.4 — this is where inbound peers get added to the routing table), marshals the closest-peers response, returns it as a replyMessage.TypePacket: pushes the decrypted payload ontotacticalNode.incoming(non-blocking; drops with a[WARN]log if the channel is full) as anapi.Message, and returns an acknowledgement-style reply. (Note: this reply is currently constructed withType: messaging.TypeDHTrather thanTypePacket— almost certainly a copy-paste artifact rather than intentional, since chat replies are documented as "fire-and-forget" and the mismatched type has no observed effect on today's flow, but worth being aware of if anything ever starts branching on the reply's type.)- Anything else is dropped with a
[WARN]log.
Classic token bucket (RateLimiter): capacity + refill-per-second, tokens computed lazily on each Allow() call from elapsed wall-clock time (no background ticker needed for the bucket itself). PerIPRateLimiter keeps one bucket per source IP in a map, with its own 5-minute cleanup loop evicting buckets idle for 30+ minutes. NodeServer uses one instance (100 capacity, 10/sec) to gate new inbound connections per IP. The DHT RoutingTable has its own, separate, simpler per-IP limiter (§10.3) gating routing-table insertions per IP — these are two independent defenses at two different layers.
XORDistance(a, b)(distance.go): byte-wise XOR of two 32-byte NodeIDs — the Kademlia distance metric.RoutingTable(routing_table.go): 256 buckets (one per possible leading-zero-bit-count of the XOR distance, i.e., per "distance class"), each capped atk=20peers.AddPeer: rejects self-insertion, rejects if the source IP has exceeded 5 additions/minute (canAddFromIP), computes the bucket index, and:- if the peer ID is already present in the bucket, refreshes its
lastSeen/statusand moves it to the back (most-recently-seen) — but does not update its storedAddresses, even if thepeerargument passed in this call carries a richer address list. This is a known limitation: an already-known peer's advertised address set is effectively frozen at first-seen. See §14.2. - if the bucket is full, checks whether the oldest entry has been silent for 15+ minutes; if so it's actively pinged (via the injected
PingFunc, wired toConnectionManager.Ping) and evicted if unreachable, making room for the new peer. Otherwise the new peer is rejected (classic Kademlia bucket behavior — long-lived peers are preferred over new ones).
- if the peer ID is already present in the bucket, refreshes its
ClosestPeers(targetID, count): flattens all buckets, sorts by XOR distance totargetID, returns the closestcount.
LookupTask(lookup.go): the iterative-lookup state machine.GetNextToQuerysorts the current shortlist by distance and returns up to α=3 unvisited peers per round (the standard Kademlia concurrency parameter);AddPeersmerges newly-discovered peers into the shortlist without duplicates;GetClosestreturns the top-k sorted results.
(There is also a internal/network/kademlia_discoverer.go implementing the same domain.Discoverer interface — it's a simpler, single-hop, non-iterative version, only exercised by its own unit tests. It is not constructed or used anywhere in pkg/node. Treat it as legacy/reference code, not part of the live system.)
Bootstrap(ctx, bootstrapPeer): adds the bootstrap peer directly to the routing table, sends oneFindNodeRPC (§10.4) searching for the local node's own ID, and adds every peer in the response to the routing table. This is called once per configured bootstrap node atStart()time.FindPeer(ctx, targetID): first checks the local routing table for an exact ID match — if found, returns immediately without any network round-trip. This short-circuit matters: it means a peer's address information is exactly whatever was true the first time it was learned, with no automatic refresh (see §14.2 and §14.3 for the resulting limitation on bootstrap-target scenarios specifically). If no exact match, seeds aLookupTaskfrom the current closest-known peers (plus the bootstrap peer as a fallback candidate) and runs the standard iterative Kademlia lookup: repeatedly take the next α=3 unvisited peers,FindNodethem all concurrently (2-second-bounded each), feed responses back into the task, until no unvisited peers remain — every responsive peer along the way is added to the routing table.Announce/Refresh: both just callFindPeer(ctx, localID)— a Kademlia self-lookup, the standard way for a node to make its presence and current address known to its network neighborhood (since neighbors add the caller to their table as a side effect of answering — see §10.4).AddLocalAddress(addr): forwards to the RPC client (§10.5) so the given address is included in theSenderAddressesfield of every future outboundFindNodeRPC. Called byrunSTUNRefreshandrunTURNRefresh(§11) whenever a new externally-reachable address is discovered.
Runs on the receiving side of a FindNode RPC (wired through appRouter.handleDHT, §9.4). Two things happen:
- Passive learning: merges the sender's self-reported
senderAddresseswith the actually-observed transport source address (mergePeerAddresses— dedups, and only includes the observed address if it round-trips throughnet.SplitHostPort), builds adomain.Peer, and callsRoutingTable.AddPeer. This is how the network learns about new nodes — there's no separate "announce" RPC type; simply answering anyFindNodequery is what teaches you about the asker. - Returns
RoutingTable.ClosestPeers(targetID, k)— the requester's own responsibility is to recurse from there.
Important gap: HandleFindNode never includes the responder's own record (with its current self-advertised addresses) in the response — a node can't add itself to its own routing table (AddPeer explicitly rejects peer.ID == localID). Combined with §10.2's exact-match short-circuit, this means: a node currently has no protocol-level way to learn a peer's STUN/TURN-discovered addresses unless it either (a) was given that address directly, or (b) happens to be queried by that peer first (at which point the querying peer's own address is what gets learned, via point 1 above — not the responder's). See §14.3 for the full implication and possible fixes.
Implements dht.RPCClient.FindNode. For every single call, this:
- Dials the target peer's known addresses via
ConnectionManager.DialAddresses(the same Happy-Eyeballs-style race described in §9.1) — a brand new QUIC connection. - Opens a fresh stream and runs the X3DH handshake as Initiator — a brand new Double Ratchet session, used for exactly this one request/response pair, then discarded (the stream and connection are
defer-closed at the end of the call). - Marshals a
pb.FindNodeRequest{TargetId, SenderAddresses: c.localAddresses}, encrypts it, frames it as amessaging.Message{Type: TypeDHT}, writes it, reads back the response frame, decrypts, unmarshals intopb.FindNodeResponse, mapsPeerInfoDTOs todomain.Peerentities (skipping and logging any malformed peer IDs rather than failing the whole lookup).
This is deliberately expensive per-call (full handshake every time) but keeps the DHT control plane using exactly the same authenticated, encrypted path as application traffic — there is no separate "cheap" unauthenticated discovery protocol.
AddLocalAddress(addr) just appends to c.localAddresses — no deduplication. A STUN refresh firing every 15 minutes with an unchanged mapped address will append the same string again on every tick; see §14.4.
There are effectively two separate NAT-traversal subsystems in this codebase at two different levels of maturity. Understanding which one is actually live matters a lot.
internal/network/nat/stun_traverser.go. STUNTraverser.GetExternalAddress(ctx, stunAddr, conn) sends a STUN binding request (github.com/pion/stun message construction) over the given net.PacketConn, loops reading responses until it gets one matching the request's transaction ID with a successful binding response class, extracts the XOR-MAPPED-ADDRESS attribute, and returns it as a string. Called from tacticalNode.runSTUNRefresh using n.demux.STUNConn() specifically — never the raw socket — so STUN traffic is cleanly demultiplexed away from concurrent QUIC traffic on the same physical socket (§12). Runs once synchronously at Start(), then every 15 minutes.
internal/network/nat/turn_allocator.go. TURNAllocator.AllocateRelay wraps github.com/pion/turn/v2: creates a turn.Client bound to a dedicated, freshly-bound local UDP socket (not the node's main socket or the demuxer — TURN gets its own physical socket entirely), calls client.Listen() then client.Allocate(), and returns the relay's public address plus the turn.Allocation object, which itself implements net.PacketConn (reads/writes are transparently wrapped/unwrapped in TURN framing by pion).
tacticalNode.runTURNRefresh then does something structurally significant: it clones the QUIC transport (QUICTransport.Clone() — same TLS config, but independent pconn/quic.Transport state) and calls ListenOnConn on the relay connection, producing a second, fully independent QUIC listener running entirely over the TURN relay, in parallel with the main listener on the demuxed local socket. Both listeners run concurrently for the life of the node; tacticalNode.turnListeners/turnTransports track them for shutdown.
Pion's TURN authentication here is the long-term credentials mechanism (turn.ClientConfig{Username, Password}) — the TURN server must be configured with lt-cred-mech and matching static credentials, not the REST-API time-limited-secret flow.
internal/domain/signaling.go, internal/transport/nat/{signaling_manager,ice,holepunch}.go. This is a considerably more sophisticated system:
domain.CandidateRecord: a signed (ed25519.Signover a canonical byte encoding), short-lived (default 60s TTL), typed (host/srflx/relay) address record.CandidateSignalingService.BuildCandidateSet: given host addresses, STUN-reflexive addresses, and TURN relay addresses, produces a signedCandidateRecordfor each.tacticalNode.refreshCandidateRecordscalls this after every STUN/TURN refresh, populatingn.candidateRecords.CandidateSignalingService.FilterAndSortCandidates: verifies signatures + peer-ID binding + expiry on a remote peer's candidate set, and sorts by priority (host > srflx > relay — seetypePriority).ConnectivityCoordinator.ProbeCandidatePairs(ice.go): the real ICE-style piece — builds the full local×remote candidate cross-product, sorted by combined priority, and races concurrent connection probes across all pairs, falling back to a relay address if nothing else connects within the deadline.HolePunchCoordinator.InitiateTraversal(holepunch.go): a simpler variant — verifies and sorts a remote candidate set, then dials all of them simultaneously and takes the first success.
Both HolePunchCoordinator and ConnectivityCoordinator are constructed in node.New and stored on tacticalNode, but neither is ever invoked from Start(), Send(), or getPeerConnection(). CandidatePathForPeer is a public-ish method on tacticalNode that would invoke ConnectivityCoordinator.ProbeCandidatePairs, but nothing in the automatic connect flow calls it. The actual live connect path (ConnectionManager.DialAddresses, §9.1) is a much simpler flat-address race with no type awareness and no priority ordering — see §14.1 for what this means in practice and what wiring it in would take.
internal/network/mux/demux.go. This is the piece that lets STUN and QUIC (and, later, x/sys/windows-cloned relay sockets are separate, but any future protocol sharing the main socket) coexist on one real UDP socket. It's also the subsystem with the most subtle history in this codebase — every fix here was hard-won, so the reasoning is preserved in detail.
type Demuxer struct {
conn net.PacketConn // the real OS socket
stunConn *virtualConn // net.PacketConn seen by STUN code
quicConn *virtualConn // net.PacketConn seen by QUIC (quic.Transport)
...
}One goroutine (readLoop) is the only code that ever calls ReadFrom on the real socket. For every packet, it classifies by first byte — STUN packets strictly start with a byte < 4 (per the STUN spec's message-type encoding), everything else is assumed to be QUIC — and delivers (virtualConn.deliver) it into the appropriate virtual connection's internal buffered channel (capacity 1024; full channels silently drop the newest packet rather than blocking readLoop, which would stall the other protocol too).
virtualConn implements net.PacketConn entirely over that channel, so from quic.Transport's point of view it's just talking to an ordinary packet connection — it has no idea a demuxer exists underneath.
Start() deliberately runs the one-shot STUN pass (over demux.STUNConn()) before calling ListenOnConn(demux.QUICConn()). The demuxer's readLoop is already running at that point (it starts in NewDemuxer), so any QUIC packets arriving during that window just queue up harmlessly on quicConn's channel — nothing is lost, it's purely about not needing QUIC to be actively draining yet while STUN does its (also blocking, 5-second-bounded) request/response cycle.
These are documented here because they're the kind of subtle, hard-to-repro issue that's likely to resurface in any similar shared-socket code added later:
-
Missing source address on read (the original, connection-breaking bug).
ReadFromreturned the named returnaddrwithout ever assigning it from the queued packet — alwaysnil.quic.Transportdemultiplexes inbound datagrams and validates dial responses by peer address; withaddralways nil, every QUIC handshake stalled to timeout. Fixed by returningpkt.addr(the addressreadLoopactually captured) instead of the unassigned zero value. Root-caused by temporarily bypassing the demuxer (pointingquic.Transportstraight at the raw socket) and observing the handshake succeed instantly — proving the bug was specifically in the demuxer adapter, not QUIC or packet classification. -
deliver()/Close()race.deliver()used to checkv.closedand release the lock before sending on the channel, leaving a window where a concurrentClose()could close the channel out from under an in-flight send, panicking withsend on closed channel. Fixed by holdingv.mufor the entire check-and-send as one atomic critical section. -
Shared, un-isolated write deadline.
stunConnandquicConnwrap the same real socket, which only has one OS-level write deadline; the original code forwardedSetWriteDeadlinestraight to the real socket, so either virtual connection could clobber the other's deadline. Fixed by trackingwriteDeadlineper-virtualConnand serializing the actualrealConn.SetWriteDeadline+WriteTopair through a*sync.Mutexshared across every virtual conn on a givenDemuxer(constructed once inNewDemuxer, passed tonewVirtualConn). This design was chosen deliberately (over a simpler no-op) because the demuxer is meant to become a general shared-socket base layer for additional future protocols (e.g. media/RTP for a video-calling use case) — each new virtual conn gets correct, isolated deadline semantics for free by following the samenewVirtualConn(conn, writeMu)pattern. -
quic-go's "connection doesn't allow setting of receive buffer size" warning.quic.Transporttries to enlarge the OS receive buffer via aSetReadBuffer(int) errortype-assertion;virtualConndidn't implement it, so quic-go silently ran without the larger buffer (harmless for light traffic, a real throughput/jitter risk under sustained load). Fixed by addingSetReadBuffer/SetWriteBuffermethods that forward directly to the real underlying socket (safe and correct: unlike the write-deadline case, OS buffer size is a genuine shared property of the fd, not something that needs per-virtual-conn isolation).Deliberately not implemented:
SyscallConn().quic.Transportalso checks for this (viaOOBCapablePacketConn) to unlock GSO/ECN batched reads/writes directly against the raw fd. IfvirtualConnexposed it, quic-go could bypassReadFrom/WriteToentirely and read/write the socket at the syscall level — which would skip the demuxer's STUN/QUIC classification inreadLoopcompletely, silently breaking routing. This is guarded by a regression test (TestVirtualConn_SyscallConn_NotExposed) asserting the method is absent, specifically so a future throughput optimization doesn't reintroduce this without re-reading why it was left out. -
TURN's relay listener still shows the receive-buffer warning, and that's expected, not a bug. The relay connection (
turn.Allocation) is pion's own type, notvirtualConn— it has no local kernel socket to resize (the relevant receive buffer, if any, lives on the coturn server, outside this process's control). This is an accurate signal from quic-go about a genuine limitation of relaying, not something fixable the same way.
demux_test.go covers PacketConn correctness (address propagation, Close() unblocking a pending ReadFrom, Close() not touching the shared real socket, deadline expiry both immediate and mid-block), packet-lifecycle behavior (multi-size packets, ordering, concurrent read/write across both virtual conns — tolerant of ordinary UDP loss but strict about payload/address correctness on whatever does arrive), and dedicated regression tests for the two concurrency bugs above. internal/transport/quic_demux_integration_test.go goes one level further and drives a real quic-go client/server handshake + stream exchange entirely through mux.Demuxer, on real UDP sockets — this is the test that would have caught the original address bug directly, since it exercises the exact production topology rather than testing either layer in isolation.
internal/transport/{quic_transport.go, tls.go, transport.go}.
Transportinterface:Dial(ctx, address, expectedNodeID) (Connection, error)andListenOnConn(net.PacketConn) (Listener, error).Connectionwraps a QUIC connection's stream multiplexing (OpenStream/AcceptStream) plusPeerNodeID().MultiDialer(implemented byConnectionManager) is the "race several addresses" abstraction used by bothdht.NetworkRPCClientandnodeHolePunchDialer.GenerateNodeTLSConfig(tls.go): builds a self-signed X.509 certificate directly from the node's Ed25519 keypair (the cert is the identity — no CA).VerifyPeerCertificateenforces the presented cert's public key is Ed25519 and self-signature-valid;ClientAuth: tls.RequireAnyClientCertmeans QUIC's TLS handshake requires mutual authentication — every connection, in both directions, must present a valid certificate.QUICTransport.Dial: additionally sets a per-dialVerifyPeerCertificateclosure that computesSHA256(peerCert.PublicKey)and compares it to the caller-suppliedexpectedNodeID— this is what pins a dial to a specific peer identity rather than merely "any validly self-signed cert."ListenOnConn: takes ownership of the givennet.PacketConn(comment explicitly warns: any protocol-specific reads on that conn, like STUN, must be finished first — QUIC becomes the sole reader from that point on) and callsquic.Transport{Conn: conn}.Listen(...)with a server-side TLS config requiring any client cert (identity is verified per-connection at a higher layer, not pinned at listen time, since a listener doesn't know in advance who will dial in).quicConnection.PeerNodeID(): readsconn.ConnectionState().TLS.PeerCertificates[0], extracts the Ed25519 public key, hashes it — this is the transport-layer identity used for the cross-check inNodeServer.handleConnection(§9.3).QUICTransport.Clone(): same TLS config, fresh/nilpconn/tr— used exclusively to give the TURN relay listener (§11.2) its own independent transport state without disturbing the main listener's.
These are real, identified gaps — documented so they're deliberate backlog items, not silent surprises for whoever picks them up next.
HolePunchCoordinator/ConnectivityCoordinator (§11.3) are fully built, including host>srflx>relay priority ordering, but ConnectionManager.DialAddresses (the code path actually used by Send/Bootstrap/FindNode) is a flat, untyped address race. Wiring this in means deciding where in the connect flow candidate exchange happens (a new signaling RPC message type, most likely) and swapping DialAddresses for ConnectivityCoordinator.ProbeCandidatePairs at the right point.
When a peer ID is already present in a bucket, only status/lastSeen are updated — the Addresses on the newly-passed-in domain.Peer are discarded. A peer's address set is effectively frozen at first-seen.
HandleFindNode never includes the responder's own record in its response (self can't be added to its own routing table), and DiscoveryManager.FindPeer short-circuits on an exact cached ID match without ever re-querying the network. Combined with §14.2, this means addresses discovered after a peer was first learned (e.g., STUN completing slightly later, TURN allocation) can't propagate to peers who only know the fixed bootstrap address and never dial in first. Candidate fixes: have HandleFindNode include a fresh self-record when appropriate, or have FindPeer periodically re-resolve even cached exact matches, or push an updated self-record over an already-open connection after the fact.
14.4 AddLocalAddress (both dht.NetworkRPCClient and, by extension, DiscoveryManager) has no deduplication
A 15-minute STUN refresh with an unchanged mapped address appends the same string again every tick, growing SenderAddresses unbounded on long-lived nodes. Needs a dedup (map-backed) or full-replace semantics instead of unconditional append.
demux.go's WriteTo/readLoop and ConnectionManager.DialAddresses log every packet/candidate synchronously via log.Printf (a global mutex + synchronous write). Fine for interactive chat traffic; a real latency/jitter risk once this carries sustained higher-rate traffic (e.g., media). Should be gated behind a debug flag before any such workload is added.
Likely a copy-paste artifact (chat replies are documented as fire-and-forget and nothing currently branches on this reply's type), but worth fixing for correctness/clarity before anything starts relying on reply message types.
file_store_windows.go's verifyFileSecure originally only checked file existence, not the actual DACL — meaning a loosened permission set on Windows would silently be accepted by Load(). This was caught by a CI test failure (TestFileIdentityStore_LoadRejectsInsecurePermissions passing on Unix, failing on Windows) and fixed by moving the real ACL-verification logic (DACL protection, single-ACE, current-user-SID checks) out of test-only code and into production (verifyCurrentUserOnlyACL, shared by both Load() and the test helper). A second, related issue was fixed alongside it: the test's method of simulating "insecure permissions" (os.Chmod) is a no-op for ACLs on Windows (it only toggles the read-only attribute), so the test needed a Windows-specific ACL-loosening helper to actually exercise the check.
pkg/node/config.go + options.go. All fields are set via Option functions passed to node.New.
| Field | Option | Default | Notes |
|---|---|---|---|
Port / ListenAddr |
WithPort |
9000 / 0.0.0.0:9000 |
WithPort keeps both in sync. |
BootstrapID / BootstrapAddr |
WithBootstrap |
— | Single legacy-style bootstrap entry. |
BootstrapNodes |
WithBootstrapNodes |
— | Preferred multi-bootstrap form. |
PublicAddr |
WithPublicAddr |
— | Explicit externally-reachable address, if known in advance. |
Passphrase |
WithPassphrase |
— / DP2PTCS_PASSPHRASE env fallback |
Never defaulted; required by Validate(). |
KeyPath |
WithKeyPath |
node.key |
|
STUNServers |
WithSTUNServers |
— | Enables §11.1. |
TURNServers |
WithTURNServers |
— | Enables §11.2; each entry needs Address/Username/Credential. |
Timeouts |
WithTimeouts |
STUN 5s, TURN 10s, Bootstrap 5s, PrekeyRotation 24h |
- Tests are co-located as
*_test.gonext to the code they cover, in the same package (white-box) except where a cross-package integration is being tested (e.g.,internal/transport/quic_demux_integration_test.goimports bothinternal/network/muxandinternal/transport). - Concurrency-sensitive code (the demuxer, session manager, rate limiter) should always be run with
-race:go test -race ./.... - The demuxer's test suite is a good model for testing a
net.PacketConnadapter thoroughly: correctness of each interface method in isolation, packet-lifecycle edge cases (sizes, ordering, concurrent access, close-during-read, deadline-during-read), and a full real-protocol integration test on top, rather than only synthetic unit tests.
cmd/node/main.go is a CLI wrapper: parses flags (-keyPath, -port, -bootstrapID, -bootstrapAddr, -stunServer, -turnServer, -turnUser, -turnPass, -chatWith), builds a node.New(...) with the corresponding Options, calls Start, and either runs a simple stdin chat loop (if -chatWith is set) or sits in passive/listen mode. examples/ contains smaller, more focused variants (basic usage, bootstrap peer/seed roles, chat sender/receiver) useful as minimal reference usage outside the CLI.