Documentation
¶
Overview ¶
Package trunk implements a per-client browser↔UDP tunnel: it bridges a binary-frame transport (WebSocket today, WebTransport in the future, or any user-provided Transport) to UDP datagrams aimed at a localhost backend. One Conn per connected client, each owning a UDP socket bound to a unique virtual loopback IP allocated by VirtualIPAllocator.
Wire format ¶
Every tunnel message is a binary frame with a 2-byte big-endian port header followed by the payload:
byte 0 byte 1 byte 2 … +---------+---------+----------+ | port (uint16, BE) | payload | +---------+---------+----------+
Port 0 is the control channel; non-zero values are UDP destination ports on the backend. Control frames are handed to [FrameDispatch.HandleControlFrame] instead of being forwarded over UDP. On connect, the Conn sends an identity frame on the control channel containing the 4-byte magic "NQIP" followed by the 4-byte virtual IPv4 address assigned to the client.
Usage ¶
alloc := trunk.NewVirtualIPAllocator(net.ParseIP(trunk.DefaultServerIP))
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
ws, err := trunk.Upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
conn, err := trunk.NewConn(trunk.WebSocket(ws), alloc, sourceKey,
trunk.WithDispatch(trunk.FrameDispatch{
IsAllowedPort: func(port int) bool { return port == 26000 },
}),
)
if err != nil {
_ = ws.Close()
return
}
conn.Run() // blocks until the connection closes
})
Index ¶
Constants ¶
const DefaultServerIP = "127.0.0.1"
DefaultServerIP is the loopback address of the NQ dedicated server that the relay forwards UDP traffic to. Override by passing a different IP to NewVirtualIPAllocator.
Variables ¶
var Upgrader = websocket.Upgrader{ HandshakeTimeout: 10 * time.Second, ReadBufferSize: 4096, WriteBufferSize: 4096, Subprotocols: []string{"binary"}, CheckOrigin: func(r *http.Request) bool { return true }, EnableCompression: false, }
Upgrader is a default WebSocket upgrader wired for binary framing with compression disabled. Exposed as a convenience — callers can build their own upgrader and still use WebSocket to wrap the resulting conn.
Functions ¶
This section is empty.
Types ¶
type Conn ¶
type Conn struct {
// contains filtered or unexported fields
}
Conn is a single client's tunnel↔UDP bridge. It owns one Transport and one UDP socket bound to a unique virtual loopback IP. Three goroutines run concurrently while Conn.Run is active: readLoop, writeLoop, and udpReadLoop; all exit when the connection closes.
func NewConn ¶
func NewConn(t Transport, alloc *VirtualIPAllocator, sourceKey string, opts ...Option) (*Conn, error)
NewConn builds a tunnel↔UDP Conn for a single client.
sourceKey is the stable identity token that determines the client's VirtualIP — the same sourceKey always hashes to the same 127.x.x.x candidate, subject to collision avoidance. alloc must be non-nil and is shared across all active Conns in the process.
Dispatch callbacks and loggers flow in through functional options. Zero options is fine: the defaults are a no-op logger and a zero-value FrameDispatch. The identity-frame magic is always the 4-byte string [defaultIdentityMagic] ("NQIP") — the token NexQuake's WASM client recognizes.
Application-layer identity (source IP for logging, user ID, admin flag) is the caller's concern; trunk does not track it.
Call Conn.Run to start the I/O loops.
func (*Conn) ActiveServerPort ¶
ActiveServerPort returns the last-routed server port.
func (*Conn) Close ¶
func (c *Conn) Close()
Close tears down the Conn: invokes HandleClose, cancels the context, closes the UDP and tunnel connections, and releases the VirtualIP. Safe to call more than once; all work happens at most once. Callers that track the Conn externally are responsible for dropping their reference.
func (*Conn) Run ¶
func (c *Conn) Run()
Run sends the identity frame, starts the UDP and tunnel I/O goroutines, and blocks until the connection closes. It calls Close before returning.
func (*Conn) VirtualIP ¶
VirtualIP returns the Conn's 127.x.x.x VirtualIP as a dotted string. Returns an empty string after the Conn has been closed.
func (*Conn) VirtualIPBytes ¶
VirtualIPBytes returns the raw 4-byte VirtualIP.
type FrameDispatch ¶
type FrameDispatch struct {
// HandleControlFrame is called for every incoming control-channel frame
// (port 0). Return a non-nil slice to send a reply on the control channel;
// return nil to send nothing. The payload slice is only valid for the
// duration of the call — copy it if you need to retain it.
HandleControlFrame func(conn *Conn, payload []byte) []byte
// HandleClose is called once when the connection is closing, before the
// tunnel and UDP sockets are torn down.
HandleClose func(conn *Conn)
// IsAllowedPort, if non-nil, is called for every incoming non-control
// frame before the payload is forwarded over UDP. Return true to allow,
// false to drop. When nil, all destination ports are allowed.
IsAllowedPort func(port int) bool
}
FrameDispatch holds application-defined callbacks that Conn invokes for frames it cannot handle internally. All fields are optional (nil is safe).
type Option ¶
type Option func(*connOptions)
Option configures a Conn at construction time. See NewConn.
func WithDispatch ¶
func WithDispatch(d FrameDispatch) Option
WithDispatch wires application-level callbacks for control-channel frames, connection close, and per-frame port gating.
func WithLogger ¶
func WithLogger(warnf, debugf logf) Option
WithLogger plumbs printf-style loggers for warnings (recoverable issues worth surfacing) and debug output (chatty, protocol-tracing level). Passing nil for either yields a no-op.
type Transport ¶
type Transport interface {
// ReadFrame blocks until a binary frame is received. On connection close
// or error it returns a non-nil error; the Conn tears down. Non-binary
// messages (e.g. WS control frames) are skipped internally.
ReadFrame() ([]byte, error)
// WriteFrame sends a binary frame with a short write deadline.
WriteFrame([]byte) error
// Ping sends an application-level keepalive. Transports with native
// connection-level keepalive (e.g. QUIC) may return nil.
Ping() error
// Close tears down the underlying connection.
Close() error
}
Transport is a bidirectional binary-frame tunnel between nexus and a single browser client. Adapters wrap the concrete connection type (WebSocket today, [WebTransport] in the future, or a custom implementation); the Conn's read/write loops are transport-agnostic.
type VirtualIPAllocator ¶
type VirtualIPAllocator struct {
// contains filtered or unexported fields
}
VirtualIPAllocator deterministically maps client source keys to unique 127.x.x.x VirtualIP addresses. The mapping is stable for a given sourceKey within a process lifetime: the same key always hashes to the same candidate, with linear probing on collision. Safe for concurrent use.
func NewVirtualIPAllocator ¶
func NewVirtualIPAllocator(serverIP net.IP) *VirtualIPAllocator
NewVirtualIPAllocator creates an allocator. serverIP must be a 127.x.x.x address (typically net.ParseIP(DefaultServerIP)); that address is excluded from the allocation pool to avoid the relay colliding with the game server itself.
func (*VirtualIPAllocator) IsBlocked ¶
func (a *VirtualIPAllocator) IsBlocked(sourceKey string) bool
IsBlocked reports whether sourceKey has been permanently blocked via VirtualIPAllocator.ReserveAndBlock. Callers can use this to reject reconnects before attempting to construct a new relay.
func (*VirtualIPAllocator) ReserveAndBlock ¶
func (a *VirtualIPAllocator) ReserveAndBlock(ip4 [4]byte, sourceKey string)
ReserveAndBlock permanently reserves ip4 so it is never re-allocated, and blocks sourceKey from receiving any future allocation. Intended for banning: call after closing a relay to ensure the banned VirtualIP is not recycled and the banned key cannot reconnect with a different VirtualIP.