netX or network extended is a collection of small, focused extensions to Go's "net" standard library.
It provides composable building blocks that integrate well with standard net.Conn and net.Listener types without introducing heavy abstractions,
only introducing new interfaces and types where necessary to expose real functionality.
- Buffered connections:
NewBufConnadds buffered read/write with explicitFlush. - Framed connections:
NewFramedConnadds a simple 4-byte length-prefixed frame protocol. - Mux / MuxClient:
NewMuxwraps anet.Listeneras anet.Conn;NewMuxClientwraps aDialeras anet.Conn— both transparently accept/redial on EOF. - Demux / DemuxClient: session multiplexer over a single
net.Connusing fixed-length ID prefixes.NewDemuxreturns anet.Listenerof virtual sessions;NewDemuxClientreturns aDialer. - Poll connections:
NewPollConnturns a request-responsenet.Conninto a persistent bidirectional stream via periodic polling. - Tagged connections:
TaggedConninterface extendsnet.Connwith opaque tags that carry context (e.g., DNS query) from read path to write path.TaggedPipeprovides an in-memory pair. - Connection router/server:
Server[ID]accepts on a listener and routes new conns to handlers you register at runtime. - Tunneling:
TunandTunMaster[ID]wire two connections together for bidirectional relay (useful to bridge UDP over a framed TCP stream, add TLS, etc.). - Driver/wrapper system: pluggable
Driverregistry and typedWrapperpipeline for composing connection transformations. Supports type-safe chains acrossnet.Listener,Dialer,net.Conn, andTaggedConn. - DNS tunneling:
proto/dnstencodes data into DNS TXT queries/responses; combine withMux,TaggedDemux,DemuxClient, andPollConnfor a full tunnel. - ICMP support:
icmptransport for listener and dialer, tunneling traffic over ICMP Echo Request/Reply. - Chainable tunnel CLI and URI builder: compose transports and wrappers with
URIin code or via thenetx tuncommand.
go get github.com/pedramktb/go-netx@latestImport as:
import netx "github.com/pedramktb/go-netx"Protocol implementations and drivers live in separate modules:
go get github.com/pedramktb/go-netx/proto/aesgcm@latest # AES-GCM conn
go get github.com/pedramktb/go-netx/proto/dnst@latest # DNS tunnel conn
go get github.com/pedramktb/go-netx/proto/ssh@latest # SSH conn
go get github.com/pedramktb/go-netx/drivers/tls@latest # TLS driver (register via blank import)
# ... etc.c, _ := net.Dial("tcp", addr)
bc := netx.NewBufConn(c, netx.WithBufSize(8<<10))
_, _ = bc.Write([]byte("hello"))
_ = bc.Flush() // ensure data is written nowNotes:
NewBufConnreturns aBufConnthat implementsnet.ConnplusFlush() error.- Options:
WithBufSize(uint16)sets both reader and writer size;WithBufReaderSize(uint16)andWithBufWriterSize(uint16)set them independently. Default: 4096. Close()will attempt toFlush()and close, returning a joined error if any.
rawClient, rawServer := net.Pipe()
defer rawClient.Close(); defer rawServer.Close()
client := netx.NewFramedConn(rawClient) // default max frame size 4096
server := netx.NewFramedConn(rawServer, netx.WithMaxFrameSize(64<<10))
msg := []byte("hello frame")
_, _ = client.Write(msg) // sends a 4-byte big-endian length header then payload
buf := make([]byte, len(msg))
_, _ = io.ReadFull(server, buf) // reads exactly one frame (may deliver across multiple Read calls)Notes:
- Each
Write(p)sends one frame. Empty frames are allowed and read asn=0, err=nil. - If an incoming frame exceeds
maxFrameSize,ReadreturnsErrFrameTooLarge. - If the underlying conn also supports
Flush(e.g.,BufConn),Writeflushes to coalesce header+payload.
NewMux adapts a net.Listener into a single net.Conn. Reads accept connections from the listener; when the current connection reaches EOF, the next one is accepted transparently. Writes go to the most recently accepted connection.
NewMuxClient does the inverse: it wraps a Dialer function as a net.Conn, dialing lazily on first use and redialing on EOF.
// Server side: collapse accepted connections into one conn
ln, _ := net.Listen("tcp", ":9000")
conn := netx.NewMux(ln) // conn implements net.Conn
// Client side: auto-reconnecting conn from a dialer
clientConn := netx.NewMuxClient(func() (net.Conn, error) {
return net.Dial("tcp", "server:9000")
}, netx.WithMuxClientRemoteAddr(remoteAddr))Notes:
- Deadlines set on the mux propagate to newly accepted/dialed connections.
- Closing the mux closes both the current connection and the underlying listener/dialer.
NewDemux is a session multiplexer: it reads from a single net.Conn, extracts a fixed-length session ID prefix from each packet, and routes payloads to virtual per-session connections exposed via a net.Listener.
NewDemuxClient creates a Dialer that produces connections which automatically prepend/strip a session ID on every write/read.
// Server: multiplex sessions over a single conn
sessListener, err := netx.NewDemux(conn, 4, // 4-byte session ID
netx.WithDemuxReadQueue(16),
netx.WithDemuxAccQueue(8),
)
if err != nil {
log.Fatal(err)
}
defer sessListener.Close()
for {
sess, _ := sessListener.Accept() // each session is a net.Conn
go handleSession(sess)
}// Client: wrap a conn with a session ID
dial := netx.NewDemuxClient(conn, []byte{0x01, 0x02, 0x03, 0x04})
sessConn, _ := dial() // net.Conn with ID prepended on writes, stripped on readsOptions:
| Option | Default | Purpose |
|---|---|---|
WithDemuxAccQueue(uint16) |
1 | Accept queue capacity |
WithDemuxReadQueue(uint16) |
128 | Per-session read queue depth |
NewPollConn converts a request-response style net.Conn into a persistent bidirectional stream. It sends user data (or empty polls on idle) and reads back responses in a continuous loop.
pollConn := netx.NewPollConn(reqRespConn,
netx.WithPollInterval(50*time.Millisecond),
netx.WithPollBufSize(4096),
netx.WithPollSendQueueSize(32),
netx.WithPollRecvQueueSize(32),
)
defer pollConn.Close()
_, _ = pollConn.Write(data) // queued, sent on next cycle
_, _ = pollConn.Read(buf) // blocks until a response arrivesThis is essential for protocols where the client must poll to receive data (e.g., DNS tunneling where the server can only respond to queries).
TaggedConn extends net.Conn semantics with an opaque any tag that carries context from the read path to the write path. This is critical for protocols where responses must correspond to specific requests (e.g., DNS queries).
type TaggedConn interface {
ReadTagged([]byte, *any) (int, error) // read payload + capture tag
WriteTagged([]byte, any) (int, error) // write payload + attach tag
Close() error
LocalAddr() net.Addr
RemoteAddr() net.Addr
SetDeadline(t time.Time) error
SetReadDeadline(t time.Time) error
SetWriteDeadline(t time.Time) error
}TaggedPipe() creates an in-memory bidirectional TaggedConn pair (analogous to net.Pipe()), useful for testing.
NewTaggedDemux is the tag-aware variant of NewDemux: it routes {payload, tag} pairs to sessions, and sessions consume tags on write so the response is constructed with the original request context.
Register handlers keyed by an ID (any comparable type). Each handler decides if it matches an incoming connection and returns an io.Closer to track (often the conn itself or a wrapped version).
var s netx.Server[string]
// Route A: TLS connections
s.SetRoute("tls", func(ctx context.Context, conn net.Conn, closed func()) (bool, io.Closer) {
if _, ok := conn.(interface{ ConnectionState() tls.ConnectionState }); !ok {
return false, nil
}
// handle TLS conn; call closed() when the connection is fully done
go func() { /* ... */ ; closed() }()
return true, conn
})
// Route B: plain connections (fallback)
s.SetRoute("plain", func(ctx context.Context, conn net.Conn, closed func()) (bool, io.Closer) {
if _, ok := conn.(interface{ ConnectionState() tls.ConnectionState }); ok {
return false, nil
}
go func() { /* ... */ ; closed() }()
return true, conn
})
ln, _ := net.Listen("tcp", ":8080")
go s.Serve(context.Background(), ln)
// Hot-swap or remove routes at runtime
s.SetRoute("plain", newHandler)
s.RemoveRoute("tls")
// Graceful shutdown
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_ = s.Shutdown(ctx) // waits for tracked connections or force-closes on deadlineHandler contract:
- Return
(matched=false, _ )quickly if the connection is not yours; the server will try the next route. - If you take ownership, return
(true, closer). Useclosed()exactly once when you are logically done so the server stops tracking it. - If you return
nilfor the closer, the server will track the originalconn. Close()immediately stops accepting and closes tracked connections.Shutdown(ctx)stops accepting and waits for tracked connections untilctxis done, after which remaining connections are force-closed.
Tun relays bytes bidirectionally between two endpoints. TunMaster[ID] builds on Server[ID] to create tunnels from accepted conns.
Bridge UDP over a framed TCP stream:
// Server side: accept a TCP stream, frame it, and relay to a UDP socket
var tm netx.TunMaster[string]
tm.SetRoute("udp-over-tcp", func(ctx context.Context, conn net.Conn) (bool, context.Context, netx.Tun) {
framed := netx.NewFramedConn(conn)
udpConn, _ := net.DialUDP("udp", nil, serverUDPAddr)
return true, ctx, netx.Tun{Conn: framed, Peer: udpConn, BufferSize: 64 << 10}
})
ln, _ := net.Listen("tcp", ":9000")
go tm.Serve(context.Background(), ln)Notes:
Tun.Relay(ctx)runs two half-duplex copies until either side closes;Close()shuts both sides.BufferSizecontrols the copy buffer (default 32KiB).TunMaster.SetRoutestartsRelayin a goroutine and calls the server'sclosed()when finished; it also logs tunnel start/close using the configuredLogger.
netX uses a pluggable driver registry and a typed wrapper pipeline for composing connection transformations.
Drivers are registered globally and instantiate Wrapper values from parameters:
// Register a custom driver (typically in an init function)
netx.Register("myproto", func(params map[string]string, listener bool) (netx.Wrapper, error) {
return netx.Wrapper{
Name: "myproto",
Params: params,
Listener: listener,
ConnToConn: func(c net.Conn) (net.Conn, error) { return myWrap(c), nil },
}, nil
})Wrappers form a typed pipeline that chains transformations. Each wrapper declares which pipe type it accepts and produces (net.Listener, Dialer, net.Conn, or TaggedConn):
// Apply wrappers manually
var wrappers netx.Wrappers
_ = wrappers.UnmarshalText([]byte("tls{cert=...,key=...}+framed"), true) // true = listener side
wrapped, _ := wrappers.Apply(rawListener) // net.Listener → net.ListenerSchemes combine a transport with wrappers:
var s netx.ListenerScheme
_ = s.UnmarshalText([]byte("tcp+tls{cert=...,key=...}+framed"))
ln, _ := s.Listen(ctx, ":9000")Built-in drivers available via blank import of drivers/* packages: aesgcm, dnst, dtls, dtlspsk, ssh, tls, tlspsk, utls. Core drivers (buffered, framed, mux, demux) are registered automatically.
The chainable URI system composes a transport, wrappers, and address into a single string. URIs follow the format <transport>+<wrapper1>{params}+<wrapper2>://<address>.
ctx := context.Background()
var listenURI netx.ListenerURI
_ = listenURI.UnmarshalText([]byte("tcp+tls{cert=...hex...,key=...hex...}://:9000"))
ln, _ := listenURI.Listen(ctx)
var dialURI netx.DialerURI
_ = dialURI.UnmarshalText([]byte("udp+aesgcm{key=...hex...}://127.0.0.1:5555"))
peerConn, _ := dialURI.Dial(ctx)
serverConn, _ := ln.Accept()
tun := netx.Tun{Conn: serverConn, Peer: peerConn}
go tun.Relay(ctx)ListenerURI.Listen and DialerURI.Dial instantiate the transport, apply each wrapper in order, and enforce type-safe pipeline validation.
You can plug any logger that implements the simple Logger interface:
type Logger interface {
DebugContext(ctx context.Context, msg string, args ...any)
InfoContext(ctx context.Context, msg string, args ...any)
WarnContext(ctx context.Context, msg string, args ...any)
ErrorContext(ctx context.Context, msg string, args ...any)
}If Logger is nil, the server/tunnel use slog.Default().
- All wrappers implement
net.Conn(orTaggedConn) where applicable to remain drop-in. - The wrapper pipeline validates type compatibility at parse time — mismatched chains fail early.
- Server routes use copy-on-write updates;
SetRoute/RemoveRouteare safe to call concurrently. - Unhandled connections are dropped immediately after all routes decline.
Shutdown(ctx)will close listeners, then wait for tracked connections untilctxis done, after which remaining connections are force-closed.- Mux and MuxClient transparently handle connection cycling (accept/redial on EOF).
- Demux sessions are fully independent
net.Connvalues with their own read queues; backpressure is per-session.
The CLI is available at cli/cmd/netx with a tun subcommand to relay between chainable endpoints.
-
Install the CLI.
go install github.com/pedramktb/go-netx/cli/cmd/netx@latest
-
Compose listener and dialer URIs. Quote them so shells do not mangle the
+,{, or,characters.netx tun \ --from "tcp+tls{cert=$(cat server.crt | xxd -p),key=$(cat server.key | xxd -p)}://:9000" \ --to "udp+aesgcm{key=00112233445566778899aabbccddeeff}://127.0.0.1:5555" -
Watch the logs. Adjust verbosity with
--log debug, or hitCtrl+Cfor a graceful shutdown.
go install github.com/pedramktb/go-netx/cli/cmd/netx@latesttask build# Show help
netx tun -h
# Example: TCP TLS server to TCP TLS+buffered+framed+aesgcm client
netx tun \
--from tcp+tls{cert=server.crt,key=server.key}://:9000 \
--to tcp+tls{cert=client.crt}+buffered{size=8192}+framed{maxsize=4096}+aesgcm{key=00112233445566778899aabbccddeeff}://example.com:9443
# Example: UDP DTLS server to UDP aesgcm client
netx tun \
--from udp+dtls{cert=server.crt,key=server.key}://:4444 \
--to udp+aesgcm{key=00112233445566778899aabbccddeeff}://10.0.0.10:5555
# Example: DNS tunnel server
netx tun \
--from udp+dnst{domain=t.example.com}+demux{id=0000,rq=16}://:53 \
--to tcp://internal-service:8080Options:
--from <chain>://listenAddr- Incoming side chain URI (required)--to <chain>://connectAddr- Peer side chain URI (required)--log <level>- Log level: debug|info|warn|error (default: info)-h- Show help
Chains use the form <transport>+<wrapper1>+<wrapper2>+...://host:port where <transport> is a base transport, optionally followed by +-separated wrappers with parameters in braces.
Supported base transports:
tcp- TCP listener or dialerudp- UDP listener or dialericmp- ICMP listener or dialer (tunnels over Echo Request/Reply)
Supported wrappers:
-
buf- Buffered read/write for better performance- Params:
r(reader size),w(writer size)
- Params:
-
frame- Length-prefixed frames for packet semantics over streams -
mux- Collapse a listener into a singlenet.Conn(server) or auto-reconnecting dialer into anet.Conn(client) -
demux- Session multiplexer over a single conn- Params:
id(hex, required for client),accq(accept queue size, optional, default: 1),rq(session read queue size, optional, default: 128)
- Params:
-
dnst- DNS tunnel encoding (Base32 in TXT queries/responses)- Params:
domain(required) - Server Params:
maxw(max payload size for writes, optional, default: 765)
- Params:
-
poll- Convert request-response conn into persistent bidirectional stream- Params:
interval(optional),sendq(optional),recvq(optional)
- Params:
-
aesgcm- AES-GCM encryption with passive IV exchange- Params:
key
- Params:
-
tls- Transport Layer Security- Server params:
cert,key - Client params:
cert(optional, for SPKI pinning),servername(required if cert not provided)
- Server params:
-
utls- TLS with client fingerprint camouflage via uTLS- Client-side only
- Params:
cert(optional, for SPKI pinning),servername(required if cert not provided),hello(optional: chrome, firefox, ios, android, safari, edge, randomized; default: chrome)
-
dtls- Datagram Transport Layer Security- Server params:
cert,key - Client params:
cert(optional, for SPKI pinning),servername(required if cert not provided)
- Server params:
-
tlspsk- TLS with pre-shared key (cipher: TLS_DHE_PSK_WITH_AES_256_CBC_SHA)- Params:
key
- Params:
-
dtlspsk- DTLS with pre-shared key (cipher: TLS_PSK_WITH_AES_128_GCM_SHA256)- Params:
key
- Params:
-
ssh- SSH tunneling via "direct-tcpip" channels- Server params:
key,pass(optional),pub(optional, required if no pass) - Client params:
pub,pass(optional),key(optional, required if no pass)
- Server params:
Notes:
- All passwords, keys and certificates must be provided as hex-encoded strings.
- When using
certfor client-sidetls/utls/dtls, default validation is disabled and a manual SPKI (SubjectPublicKeyInfo) hash comparison is performed against the provided certificate. This is certificate pinning and will fail if the server presents a different key. - SSH server must accept "direct-tcpip" channels (most do by default).
- See docs/mux-tag-poll.md for the full architecture and data-flow diagrams of the mux/demux/poll/tagged system.