A minimal, runnable proof of the "proto services behind a Transport seam" pattern from the Slim Directory redesign:
the same proto-generated gRPC service and client work over libp2p (gostream) for remote/federation and in-process (bufconn / UDS) for local — with nothing above the seam knowing which transport is in use.
This repository is the starting point for exploring the AGNTCY Directory Transport seam and its evolution toward production-ready federation, NAT traversal, edge connectivity, observability, and customer-facing deployments.
The next stages will explore:
- production-ready libp2p transports over TCP and QUIC;
- AutoNAT, Circuit Relay v2 and DCUtR for NAT traversal;
- CGNAT, mobile network and mobile VPN scenarios;
- Directory and team-server federation;
- delegated routing and provider discovery;
- transport-aware observability and OpenTelemetry integration;
- integration with AGNTCY applications such as Spellguard.ai and coffee-sdk-app;
- interoperability, integration and end-to-end testing;
- potential integration with overlay networks such as Tailscale/WireGuard where appropriate.
The development approach will be incremental, with small PRs, milestones, implementation reviews and interoperability testing.
For the proposed roadmap, milestones, PR areas, NAT traversal test matrix, customer scenarios, success criteria and longer-term direction, see:
AGNTCY Directory Transport Seam — Roadmap, NAT Traversal, and Integration
Contributions and reviews are welcome, particularly around libp2p protocol boundaries, gostream, QUIC, peer identity, AutoNAT, Circuit Relay v2, DCUtR, resource management, protocol versioning, NAT/CGNAT behaviour, interoperability and observability.
- One proto service (
DirService:Ping,Push,Get) — the generic API. - One server implementation (
internal/service) — an in-memory content-addressed store. - One small
Transportinterface (transport/transport.go) with five interchangeable implementations:
| Transport | File | Role in the design |
|---|---|---|
| Bufconn (in-memory pipe) | transport/bufconn.go |
LOCAL, in-process — slim CLI one-shot commands; no sockets, no libp2p host |
| UDS (unix socket) | transport/uds.go |
LOCAL, persistent daemon serving multiple CLI invocations; no network port |
| TCP + mTLS | transport/tcptls.go |
REMOTE, networked — cert-based mutual auth at the gRPC layer; the public / enterprise edge |
libp2p over TCP (gRPC via gostream) |
transport/libp2p.go |
REMOTE / federation — encrypted, authenticated, NAT-traversing; no public gRPC port |
| libp2p over QUIC | same transport/libp2p.go |
Same code as above; only the libp2p listen address changed (/udp/…/quic-v1). Shows the wire transport lives below the seam |
The demo (cmd/demo) starts the exact same server + client over each transport
in turn and runs Ping/Push/Get. mTLS certs are generated in-process by
internal/tlsca (demo only — real deployments load certs from a secret store /
SPIFFE).
Note the Transport interface also exposes ServerOptions() so a transport can
supply server-side options (e.g. TLS grpc.Creds) at grpc.NewServer time;
bufconn/UDS/libp2p return none (libp2p is already encrypted/authenticated).
go run ./cmd/demoExpected output:
=== Transport seam demo: one gRPC service, five transports ===
--- bufconn (local, in-process) ---
Ping -> "pong: hello" (served_by="bufconn (local, in-process)")
Push -> cid=b1e1bf3c...
Get -> found=true data="agent record via bufconn (local, in-process)"
--- uds (local socket) ---
Ping -> "pong: hello" (served_by="uds (local socket)")
...
--- tcp + mTLS (networked) ---
Ping -> "pong: hello" (served_by="tcp + mTLS (networked)")
...
--- libp2p (gostream over TCP) ---
Ping -> "pong: hello" (served_by="libp2p (gostream over TCP)")
...
--- libp2p (gostream over QUIC) ---
Ping -> "pong: hello" (served_by="libp2p (gostream over QUIC)")
...
type Transport interface {
// ServerOptions supplies options applied when constructing the grpc.Server
// (e.g. TLS grpc.Creds). Most transports return nil.
ServerOptions() []grpc.ServerOption
// Serve binds a net.Listener to the server and blocks.
Serve(ctx context.Context, srv *grpc.Server) error
// Dial returns a *grpc.ClientConn reaching the served endpoint.
Dial(ctx context.Context) (*grpc.ClientConn, error)
Close() error
}- Server construction:
grpc.NewServer(tr.ServerOptions()...)— a transport can inject server options (TCP+mTLS suppliesgrpc.Creds; the others return nil). - Server side: each transport hands a
net.Listenertogrpc.Server.Serve— abufconn.Listener, a unixnet.Listener, a TCPnet.Listener, orgostream.Listen(host, protoID). - Client side: each transport returns a
*grpc.ClientConnviagrpc.NewClient(...)— with a customgrpc.WithContextDialer(bufconn, libp2p) or a resolvable target (unix:,host:portfor TCP), plus the rightgrpc.WithTransportCredentials(insecure locally, TLS for TCP+mTLS). - Above the seam: controllers/clients only use the generated stubs
(
dirv1.NewDirServiceClient) and never import libp2p, bufconn, or TLS config.
- bufconn/UDS: insecure creds — traffic never leaves the host (same process / local socket).
- libp2p (TCP & QUIC): insecure gRPC creds because the libp2p stream is already mutually authenticated and encrypted (Noise/TLS) with the peer ID as identity — gRPC-layer TLS would be redundant.
- TCP + mTLS: real TLS credentials on both sides (
credentials.NewTLS); the server requires and verifies the client cert (mutual TLS), certs minted byinternal/tlsca. - In production, add gRPC interceptors for authorization on top of any of these.
A libp2p host cannot dial itself, so the demo uses two hosts (server + client). In a real node the client host is your local node and the server host is a remote peer / team server.
proto/dir/v1/dir.proto # the service contract (IDL)
gen/dir/v1/ # generated stubs (buf generate)
internal/service/ # DirService implementation (in-memory store)
internal/tlsca/ # ephemeral in-process mTLS PKI (demo only)
transport/ # Transport seam + bufconn / uds / tcptls / libp2p impls
cmd/demo/ # runs all five transports
buf.yaml, buf.gen.yaml # codegen config
Regenerate stubs after editing the proto:
PATH="$PATH:$(go env GOPATH)/bin" buf generateAn overlay network is a virtual network layered on top of the physical Internet (the "underlay"). Nodes get stable virtual identities/addresses and talk as if on one private LAN, while packets are encrypted and routed over the real Internet. Examples: WireGuard, Tailscale, ZeroTier, Nebula (and libp2p is arguably an overlay too). They typically provide:
- Virtual addressing — reach a node by a stable name/IP regardless of where it physically is or what NAT it's behind.
- Encryption — usually WireGuard or Noise, end-to-end.
- NAT traversal — direct hole punching with relay fallback.
- Identity + ACLs — authenticated devices and policy over who can reach whom.
This decouples who/where a service is from its physical IP — the same problem libp2p solves, via a different mechanism.
tsnet (tailscale.com/tsnet) embeds a full Tailscale node inside your Go
process — no separate tailscaled daemon and no OS-level TUN device (it uses
userspace WireGuard + a userspace TCP/IP stack). Your process joins the tailnet
as its own device and gives you standard net.Listener / net.Conn on the
overlay, so it drops straight into the same Transport seam:
// server side
ts := &tsnet.Server{Hostname: "dir-node"} // + TS_AUTHKEY in env
lis, _ := ts.Listen("tcp", ":8888") // reachable from anywhere on the tailnet
grpcSrv.Serve(lis)
// client side
tc := &tsnet.Server{Hostname: "dir-client"}
conn, _ := grpc.NewClient("passthrough:///dir",
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
return tc.Dial(ctx, "tcp", "dir-node:8888")
}),
grpc.WithTransportCredentials(insecure.NewCredentials()))Under the hood tsnet connects to Tailscale's coordination server for key exchange + peer discovery, does direct UDP hole punching, and falls back to DERP relays (conceptually like libp2p's relay + DCUtR).
tsnet vs libp2p for the directory:
- Both give encrypted, authenticated, NAT-traversing connectivity + stable identity.
- tsnet: excellent ergonomics, real centrally-managed ACLs, MagicDNS names — but depends on a coordination server (Tailscale SaaS or self-hosted Headscale), i.e. a central control plane (data plane stays P2P).
- libp2p: no mandatory central coordinator (DHT + bootstrap + relays), native content-addressing and P2P primitives — more decentralized, more assembly.
tsnet is not wired into the runnable demo because it needs a Tailscale auth
key (TS_AUTHKEY) and network access to a tailnet. It's shown here as a
pluggable option: a team could use a tsnet/WireGuard overlay as its fabric
instead of (or alongside) libp2p relays.
go.mod contains:
exclude github.com/libp2p/go-libp2p/core v0.43.0-rc2
go-libp2p v0.48.0 still bundles the core/* packages in its main module, but a
standalone github.com/libp2p/go-libp2p/core module now also exists (a
release-candidate from an in-progress split). If that standalone module enters
the build list, Go attributes go-libp2p's core/* imports to it and they
collide with the bundled copies (ambiguous import). The exclude keeps
core/* resolving from go-libp2p v0.48.0.
go mod tidy currently fails (it chases test-only
imports that pull the standalone core module). Use go build ./... /
go run ./cmd/demo — both work. This matches the go-libp2p version dir uses;
it should resolve once the go-libp2p core split lands in a stable release.