Skip to content

NAT Relay

The NAT relay functionality extends bridge mode (internal/bridge) to forward UDP packets between peers that cannot establish direct peer-to-peer WireGuard tunnels. A bridge node configured as a relay opens a UDP listener and relays packets between assigned peer pairs based on session assignments from the control plane.

Data Flow

Peer A                                                      Peer B
(behind NAT)                                                (behind NAT)
    │                                                           ▲
    │  UDP packet                                  UDP packet   │
    ▼                                                           │
┌──────────────────────────────────────────────────────────────────┐
│                     Bridge Node (Relay)                          │
│                                                                  │
│  ┌──────────────┐    ┌──────────────┐    ┌───────────────┐      │
│  │  UDP Listener │───▶│  addrIndex   │───▶│ RelaySession  │      │
│  │  (port 51821) │    │  O(1) lookup │    │  Forward()    │      │
│  └──────────────┘    └──────────────┘    └───────────────┘      │
│         ▲                                        │               │
│         │              dispatchLoop              │               │
│         │         (pre-allocated 64KB buf)        ▼               │
│         └────────────────────────────────────────┘               │
│                                                                  │
│  Control Plane ──SSE──▶ bridge_config_updated ─▶ reconcile       │
│                ──Rec──▶ RelayReconcileHandler                    │
└──────────────────────────────────────────────────────────────────┘

The relay uses a single shared UDP socket. Incoming packets are dispatched to sessions via an addrIndex map keyed by net.UDPAddr.String() for O(1) lookup. Each RelaySession knows both peer addresses and forwards packets to the opposite peer.

Config

Relay fields extend the existing bridge Config struct. Relay requires bridge mode to be enabled (Enabled=true).

FieldTypeDefaultDescription
RelayEnabledboolfalseWhether the bridge node serves as a relay
RelayListenPortint51821UDP port for relay traffic
MaxRelaySessionsint100Maximum concurrent relay sessions
SessionTTLtime.Duration5mTime-to-live for relay sessions
go
cfg := bridge.Config{
    Enabled:         true,
    AccessInterface: "eth1",
    AccessSubnets:   []string{"10.0.0.0/24"},
    RelayEnabled:    true,
    RelayListenPort: 51821,
}
cfg.ApplyDefaults() // sets MaxRelaySessions=100, SessionTTL=5m if zero
if err := cfg.Validate(); err != nil {
    log.Fatal(err)
}

Defaults

ApplyDefaults() sets zero-valued relay fields:

FieldZero ValueDefault Applied
RelayListenPort0DefaultRelayListenPort (51821)
MaxRelaySessions0DefaultMaxRelaySessions (100)
SessionTTL0DefaultSessionTTL (5 minutes)

Validation Rules

Relay validation is skipped when RelayEnabled is false. When enabled:

FieldRuleError Message
RelayEnabledRequires Enabled=truebridge: config: relay requires bridge mode to be enabled
RelayListenPortMust be 1-65535bridge: config: RelayListenPort must be between 1 and 65535
MaxRelaySessionsMust be > 0bridge: config: MaxRelaySessions must be positive when relay is enabled
SessionTTLMust be >= 30sbridge: config: SessionTTL must be at least 30s

Relay

Relay manages a UDP listener and relay sessions. Created by NewRelay and integrated into the Manager.

Constructor

go
func NewRelay(listenPort, maxSessions int, sessionTTL time.Duration, logger *slog.Logger) *Relay

Methods

MethodSignatureDescription
Start(ctx context.Context) errorOpens UDP socket, starts dispatch loop goroutine
Stop() errorCloses all sessions and UDP listener; idempotent
AddSession(assignment api.RelaySessionAssignment) errorCreates and registers a new relay session
RemoveSession(sessionID string)Closes and removes a session by ID; no-op if not found
ActiveCount() intReturns the number of active relay sessions
SessionIDs() []stringReturns the IDs of all active sessions
ListenAddr() net.AddrReturns the local address of the UDP listener; nil if not started

Lifecycle

go
relay := bridge.NewRelay(51821, 100, 5*time.Minute, logger)

// Start listening
if err := relay.Start(ctx); err != nil {
    log.Fatal(err)
}

// Add a session (typically driven by SSE handler or reconciliation)
err := relay.AddSession(api.RelaySessionAssignment{
    SessionID:     "sess-1",
    PeerAEndpoint: "203.0.113.1:51820",
    PeerBEndpoint: "198.51.100.1:51820",
    ExpiresAt:     time.Now().Add(5 * time.Minute),
})

// Remove a session
relay.RemoveSession("sess-1")

// Graceful shutdown
relay.Stop()

AddSession

  1. Resolves PeerAEndpoint and PeerBEndpoint via net.ResolveUDPAddr
  2. Rejects duplicate session IDs and sessions beyond maxSessions
  3. Creates a RelaySession with the shared UDP connection
  4. Registers both peer addresses in addrIndex for O(1) dispatch
  5. Starts a TTL timer — uses min(sessionTTL, time.Until(ExpiresAt))

Dispatch Loop

The dispatchLoop goroutine reads from the shared UDP socket using a pre-allocated 65535-byte buffer (no per-packet allocation):

  1. Read packet from UDP socket
  2. Copy data to a new slice (buffer reuse)
  3. Look up session by source address in addrIndex (O(1) via RLock)
  4. If found, call session.Forward(srcAddr, data)
  5. If not found, log at debug level and continue

Context cancellation closes the UDP connection, causing ReadFromUDP to return an error and the loop to exit.

Concurrency

  • Relay.mu (sync.RWMutex) protects sessions, addrIndex, timers, conn, active
  • RelaySession.mu (sync.Mutex) protects the closed flag
  • dispatchLoop receives the conn as a parameter to avoid racing with Stop() which sets r.conn = nil
  • TTL timer callbacks call RemoveSession which acquires the write lock

RelaySession

Represents a single relay session forwarding UDP packets between two peers.

go
type RelaySession struct {
    SessionID string
    PeerAAddr *net.UDPAddr
    PeerBAddr *net.UDPAddr
}

Forward

Forward(srcAddr *net.UDPAddr, data []byte) determines the destination by matching the source address:

Source matchesDestination
PeerAAddrPeerBAddr
PeerBAddrPeerAAddr
NeitherDropped (logged at debug)

Close

Close() is idempotent — calling it multiple times returns nil.

SSE Event Handling

There are no relay-specific SSE handlers. The control plane emits a single bridge_config_updated event with an opaque payload; bridge.HandleBridgeConfigUpdated dispatches it to TriggerReconcile(), and the RelayReconcileHandler below applies the desired relay subtree from the authoritative state snapshot.

go
dispatcher := api.NewEventDispatcher(logger)
dispatcher.Register(api.EventBridgeConfigUpdated,
    bridge.HandleBridgeConfigUpdated(reconciler))

RelayReconcileHandler

go
func RelayReconcileHandler(relay *Relay, logger *slog.Logger) reconcile.ReconcileHandler

Returns a reconcile.ReconcileHandler that synchronizes relay sessions to match the desired bridge relay subtree. The handler is presence-aware: a null Bridge or null Relay child means "not populated", so it reconciles against an empty desired set and tears down stale sessions.

  1. Reads sessions from desired.Bridge.Relay.Sessions (empty when Bridge or Relay is nil)
  2. Builds a desired set keyed by SessionID
  3. Removes stale sessions: current IDs not in the desired set
  4. Adds missing sessions: desired sessions not in the current set
  5. Aggregates AddSession errors via errors.Join

Registration

go
r := reconcile.NewReconciler(client, reconcile.Config{}, logger)
r.RegisterHandler(bridge.RelayReconcileHandler(bridgeMgr.Relay(), logger))

Manager Integration

The Manager creates and manages the Relay when Config.RelayEnabled is true.

Manager Relay Methods

MethodSignatureDescription
Relay() *RelayReturns the relay instance; nil if not configured
StartRelay(ctx context.Context) errorStarts the relay UDP listener; no-op if nil
StopRelay() errorStops the relay; no-op if nil

Teardown() automatically calls relay.Stop() as part of bridge teardown. Errors are aggregated with other teardown errors.

BridgeStatus with Relay

When relay is configured, BridgeStatus() includes relay fields:

go
info := mgr.BridgeStatus()
// info.RelayEnabled = true
// info.ActiveRelaySessions = relay.ActiveCount()

BridgeCapabilities with Relay

When relay is enabled, BridgeCapabilities() includes:

go
caps := mgr.BridgeCapabilities()
// caps["relay"] = "true"
// caps["relay_listen_port"] = "51821"

Full Lifecycle

go
mgr := bridge.NewManager(ctrl, bridge.Config{
    Enabled:         true,
    AccessInterface: "eth1",
    AccessSubnets:   []string{"10.0.0.0/24"},
    RelayEnabled:    true,
}, logger)

// Setup bridge routing
mgr.Setup("plexd0")

// Start relay UDP listener
mgr.StartRelay(ctx)

// Register handlers
r := reconcile.NewReconciler(client, reconcile.Config{}, logger)
r.RegisterHandler(bridge.RelayReconcileHandler(mgr.Relay(), logger))

dispatcher := api.NewEventDispatcher(logger)
dispatcher.Register(api.EventBridgeConfigUpdated,
    bridge.HandleBridgeConfigUpdated(r))

// Run reconciler
go r.Run(ctx, nodeID)

// Graceful shutdown
<-ctx.Done()
mgr.Teardown() // stops relay, removes routes, disables forwarding

API Types

RelayConfig

Pushed from the control plane in the snapshot bridge.relay subtree (api.BridgeSnapshot.Relay), present-but-nullable — a null value tears down active sessions.

go
type RelayConfig struct {
    Sessions []RelaySessionAssignment `json:"sessions"`
}

RelaySessionAssignment

Represents a single relay session assignment.

go
type RelaySessionAssignment struct {
    SessionID     string    `json:"session_id"`
    PeerAID       string    `json:"peer_a_id"`
    PeerAEndpoint string    `json:"peer_a_endpoint"`
    PeerBID       string    `json:"peer_b_id"`
    PeerBEndpoint string    `json:"peer_b_endpoint"`
    ExpiresAt     time.Time `json:"expires_at"`
}
FieldDescription
SessionIDUnique identifier for the relay session
PeerAIDNode ID of peer A
PeerAEndpointUDP endpoint of peer A (host:port)
PeerBIDNode ID of peer B
PeerBEndpointUDP endpoint of peer B (host:port)
ExpiresAtAbsolute expiry time for the session

BridgeInfo Relay Fields

Reported in heartbeats via api.BridgeInfo:

FieldTypeDescription
RelayEnabledboolWhether relay is active on this node
ActiveRelaySessionsintNumber of currently active relay sessions

SSE Event Constants

Relay changes are delivered through the single bridge event constant; the fine-grained relay_* constants have been removed.

ConstantValue
api.EventBridgeConfigUpdated"bridge_config_updated"

Error Prefixes

SourcePrefix
Relay.Startbridge: relay: listen on :<port>:
Relay.AddSession (resolve)bridge: relay: resolve peer A/B endpoint
Relay.AddSession (dup)bridge: relay: duplicate session ID:
Relay.AddSession (max)bridge: relay: max sessions reached

Logging

All relay log entries use component=bridge.

LevelEventKeys
InfoRelay startedlisten_port
InfoRelay stopped
InfoRelay session addedsession_id, peer_a, peer_b, ttl
InfoRelay session closedsession_id
DebugPacket from unregistered addrsource
DebugDropping packet (unknown src)session_id, source
ErrorForward failedsession_id, dst, error
ErrorRelay reconcile: add failedsession_id, error