Add support for renomination

This commit is contained in:
Kevin Wang
2025-08-16 23:21:24 -04:00
parent fb0107e8ae
commit 4b50bc8078
7 changed files with 1321 additions and 3 deletions

View File

@@ -149,10 +149,25 @@ type Agent struct {
proxyDialer proxy.Dialer
enableUseCandidateCheckPriority bool
// Renomination support
enableRenomination bool
nominationValueGenerator func() uint32
nominationAttribute stun.AttrType
}
// NewAgent creates a new Agent.
func NewAgent(config *AgentConfig) (*Agent, error) { //nolint:gocognit,cyclop
func NewAgent(config *AgentConfig) (*Agent, error) {
return newAgentWithConfig(config)
}
// NewAgentWithOptions creates a new Agent with options only.
func NewAgentWithOptions(opts ...AgentOption) (*Agent, error) {
return newAgentWithConfig(&AgentConfig{}, opts...)
}
// newAgentWithConfig is the internal function that creates an agent with config and options.
func newAgentWithConfig(config *AgentConfig, opts ...AgentOption) (*Agent, error) { //nolint:cyclop
var err error
if config.PortMax < config.PortMin {
return nil, ErrPort
@@ -225,7 +240,12 @@ func NewAgent(config *AgentConfig) (*Agent, error) { //nolint:gocognit,cyclop
userBindingRequestHandler: config.BindingRequestHandler,
enableUseCandidateCheckPriority: config.EnableUseCandidateCheckPriority,
enableRenomination: false,
nominationValueGenerator: nil,
nominationAttribute: stun.AttrType(0x0030), // Default value
}
agent.connectionStateNotifier = &handlerNotifier{
connectionStateFunc: agent.onConnectionStateChange,
done: make(chan struct{}),
@@ -327,6 +347,15 @@ func NewAgent(config *AgentConfig) (*Agent, error) { //nolint:gocognit,cyclop
return nil, err
}
for _, opt := range opts {
if err := opt(agent); err != nil {
agent.closeMulticastConn()
_ = agent.Close()
return nil, err
}
}
return agent, nil
}
@@ -1364,3 +1393,65 @@ func (a *Agent) getSelector() pairCandidateSelector {
return a.selector
}
// getNominationValue returns a nomination value if generator is available, otherwise 0.
func (a *Agent) getNominationValue() uint32 {
if a.nominationValueGenerator != nil {
return a.nominationValueGenerator()
}
return 0
}
// RenominateCandidate allows the controlling ICE agent to nominate a new candidate pair.
// This implements the continuous renomination feature from draft-thatcher-ice-renomination-01.
func (a *Agent) RenominateCandidate(local, remote Candidate) error {
if !a.isControlling.Load() {
return ErrOnlyControllingAgentCanRenominate
}
if !a.enableRenomination {
return ErrRenominationNotEnabled
}
// Find the candidate pair
pair := a.findPair(local, remote)
if pair == nil {
return ErrCandidatePairNotFound
}
// Send nomination with custom attribute
return a.sendNominationRequest(pair, a.getNominationValue())
}
// sendNominationRequest sends a nomination request with custom nomination value.
func (a *Agent) sendNominationRequest(pair *CandidatePair, nominationValue uint32) error {
attributes := []stun.Setter{
stun.TransactionID,
stun.NewUsername(a.remoteUfrag + ":" + a.localUfrag),
UseCandidate(),
AttrControlling(a.tieBreaker),
PriorityAttr(pair.Local.Priority()),
stun.NewShortTermIntegrity(a.remotePwd),
stun.Fingerprint,
}
// Add nomination attribute if renomination is enabled and value > 0
if a.enableRenomination && nominationValue > 0 {
attributes = append(attributes, NominationSetter{
Value: nominationValue,
AttrType: a.nominationAttribute,
})
a.log.Tracef("Sending renomination request from %s to %s with nomination value %d",
pair.Local, pair.Remote, nominationValue)
}
msg, err := stun.Build(append([]stun.Setter{stun.BindingRequest}, attributes...)...)
if err != nil {
return fmt.Errorf("failed to build nomination request: %w", err)
}
a.sendBindingRequest(msg, pair.Local, pair.Remote)
return nil
}

69
agent_options.go Normal file
View File

@@ -0,0 +1,69 @@
// SPDX-FileCopyrightText: 2025 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package ice
import (
"sync/atomic"
"github.com/pion/stun/v3"
)
// AgentOption represents a function that can be used to configure an Agent.
type AgentOption func(*Agent) error
// NominationValueGenerator is a function that generates nomination values for renomination.
type NominationValueGenerator func() uint32
// DefaultNominationValueGenerator returns a generator that starts at 1 and increments for each call.
// This provides a simple, monotonically increasing sequence suitable for renomination.
func DefaultNominationValueGenerator() NominationValueGenerator {
var counter atomic.Uint32
return func() uint32 {
return counter.Add(1)
}
}
// WithRenomination enables ICE renomination as described in draft-thatcher-ice-renomination-01.
// When enabled, the controlling agent can renominate candidate pairs multiple times
// and the controlled agent follows "last nomination wins" rule.
//
// The generator parameter specifies how nomination values are generated.
// Use DefaultNominationValueGenerator() for a simple incrementing counter,
// or provide a custom generator for more complex scenarios.
//
// Example:
//
// agent, err := NewAgentWithOptions(config, WithRenomination(DefaultNominationValueGenerator()))
func WithRenomination(generator NominationValueGenerator) AgentOption {
return func(a *Agent) error {
if generator == nil {
return ErrInvalidNominationValueGenerator
}
a.enableRenomination = true
a.nominationValueGenerator = generator
return nil
}
}
// WithNominationAttribute sets the STUN attribute type to use for ICE renomination.
// The default value is 0x0030. This can be configured until the attribute is officially
// assigned by IANA for draft-thatcher-ice-renomination.
//
// This option returns an error if the provided attribute type is invalid.
// Currently, validation ensures the attribute is not 0x0000 (reserved).
// Additional validation may be added in the future.
func WithNominationAttribute(attrType uint16) AgentOption {
return func(a *Agent) error {
// Basic validation: ensure it's not the reserved 0x0000
if attrType == 0x0000 {
return ErrInvalidNominationAttribute
}
a.nominationAttribute = stun.AttrType(attrType)
return nil
}
}

110
agent_options_test.go Normal file
View File

@@ -0,0 +1,110 @@
// SPDX-FileCopyrightText: 2025 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package ice
import (
"testing"
"github.com/pion/stun/v3"
"github.com/stretchr/testify/assert"
)
func TestDefaultNominationValueGenerator(t *testing.T) {
t.Run("generates incrementing values", func(t *testing.T) {
generator := DefaultNominationValueGenerator()
// Should generate incrementing values starting from 1
assert.Equal(t, uint32(1), generator())
assert.Equal(t, uint32(2), generator())
assert.Equal(t, uint32(3), generator())
})
t.Run("each generator has independent counter", func(t *testing.T) {
gen1 := DefaultNominationValueGenerator()
gen2 := DefaultNominationValueGenerator()
assert.Equal(t, uint32(1), gen1())
assert.Equal(t, uint32(1), gen2()) // Should also start at 1
assert.Equal(t, uint32(2), gen1())
assert.Equal(t, uint32(2), gen2())
})
}
func TestWithRenomination(t *testing.T) {
t.Run("enables renomination with custom generator", func(t *testing.T) {
counter := uint32(0)
customGen := func() uint32 {
counter++
return counter * 10
}
agent, err := NewAgentWithOptions(WithRenomination(customGen))
assert.NoError(t, err)
defer agent.Close() //nolint:errcheck
assert.True(t, agent.enableRenomination)
assert.NotNil(t, agent.nominationValueGenerator)
assert.Equal(t, uint32(10), agent.getNominationValue())
assert.Equal(t, uint32(20), agent.getNominationValue())
})
t.Run("enables renomination with default generator", func(t *testing.T) {
agent, err := NewAgentWithOptions(WithRenomination(DefaultNominationValueGenerator()))
assert.NoError(t, err)
defer agent.Close() //nolint:errcheck
assert.True(t, agent.enableRenomination)
assert.NotNil(t, agent.nominationValueGenerator)
assert.Equal(t, uint32(1), agent.getNominationValue())
assert.Equal(t, uint32(2), agent.getNominationValue())
})
t.Run("rejects nil generator", func(t *testing.T) {
_, err := NewAgentWithOptions(WithRenomination(nil))
assert.ErrorIs(t, err, ErrInvalidNominationValueGenerator)
})
t.Run("default agent has renomination disabled", func(t *testing.T) {
config := &AgentConfig{
NetworkTypes: []NetworkType{NetworkTypeUDP4},
}
agent, err := NewAgent(config)
assert.NoError(t, err)
defer agent.Close() //nolint:errcheck
assert.False(t, agent.enableRenomination)
assert.Nil(t, agent.nominationValueGenerator)
assert.Equal(t, uint32(0), agent.getNominationValue())
})
}
func TestWithNominationAttribute(t *testing.T) {
t.Run("sets custom nomination attribute", func(t *testing.T) {
agent, err := NewAgentWithOptions(WithNominationAttribute(0x0045))
assert.NoError(t, err)
defer agent.Close() //nolint:errcheck
assert.Equal(t, stun.AttrType(0x0045), agent.nominationAttribute)
})
t.Run("rejects invalid attribute 0x0000", func(t *testing.T) {
_, err := NewAgentWithOptions(WithNominationAttribute(0x0000))
assert.ErrorIs(t, err, ErrInvalidNominationAttribute)
})
t.Run("default value when no option", func(t *testing.T) {
config := &AgentConfig{
NetworkTypes: []NetworkType{NetworkTypeUDP4},
}
agent, err := NewAgent(config)
assert.NoError(t, err)
defer agent.Close() //nolint:errcheck
// Should use default value 0x0030
assert.Equal(t, stun.AttrType(0x0030), agent.nominationAttribute)
})
}

View File

@@ -120,6 +120,21 @@ var (
// ErrDetermineNetworkType indicates that the NetworkType was not able to be parsed.
ErrDetermineNetworkType = errors.New("unable to determine networkType")
// ErrOnlyControllingAgentCanRenominate indicates that only controlling agent can renominate.
ErrOnlyControllingAgentCanRenominate = errors.New("only controlling agent can renominate")
// ErrRenominationNotEnabled indicates that renomination is not enabled.
ErrRenominationNotEnabled = errors.New("renomination is not enabled")
// ErrCandidatePairNotFound indicates that candidate pair was not found.
ErrCandidatePairNotFound = errors.New("candidate pair not found")
// ErrInvalidNominationAttribute indicates an invalid nomination attribute type was provided.
ErrInvalidNominationAttribute = errors.New("invalid nomination attribute type")
// ErrInvalidNominationValueGenerator indicates a nil nomination value generator was provided.
ErrInvalidNominationValueGenerator = errors.New("nomination value generator cannot be nil")
errAttributeTooShortICECandidate = errors.New("attribute not long enough to be ICE candidate")
errClosingConnection = errors.New("failed to close connection")
errConnectionAddrAlreadyExist = errors.New("connection with same remote address already exists")

86
renomination.go Normal file
View File

@@ -0,0 +1,86 @@
// SPDX-FileCopyrightText: 2025 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package ice
import (
"fmt"
"github.com/pion/stun/v3"
)
// Default STUN Nomination attribute type for ICE renomination.
// Following the specification draft-thatcher-ice-renomination-01.
const (
// DefaultNominationAttribute represents the default STUN Nomination attribute.
// This is a custom attribute for ICE renomination support.
// This value can be overridden via AgentConfig.NominationAttribute.
DefaultNominationAttribute stun.AttrType = 0x0030 // Using a value in the reserved range
)
// NominationAttribute represents a STUN Nomination attribute.
type NominationAttribute struct {
Value uint32
}
// GetFrom decodes a Nomination attribute from a STUN message.
func (a *NominationAttribute) GetFrom(m *stun.Message) error {
return a.GetFromWithType(m, DefaultNominationAttribute)
}
// GetFromWithType decodes a Nomination attribute from a STUN message using a specific attribute type.
func (a *NominationAttribute) GetFromWithType(m *stun.Message, attrType stun.AttrType) error {
v, err := m.Get(attrType)
if err != nil {
return err
}
if len(v) < 4 {
return stun.ErrAttributeSizeInvalid
}
// Extract 24-bit value from the last 3 bytes
a.Value = uint32(v[1])<<16 | uint32(v[2])<<8 | uint32(v[3])
return nil
}
// AddTo adds a Nomination attribute to a STUN message.
func (a NominationAttribute) AddTo(m *stun.Message) error {
return a.AddToWithType(m, DefaultNominationAttribute)
}
// AddToWithType adds a Nomination attribute to a STUN message using a specific attribute type.
func (a NominationAttribute) AddToWithType(m *stun.Message, attrType stun.AttrType) error {
// Store as 4 bytes with first byte as 0
v := make([]byte, 4)
v[1] = byte(a.Value >> 16)
v[2] = byte(a.Value >> 8)
v[3] = byte(a.Value)
m.Add(attrType, v)
return nil
}
// String returns string representation of the nomination attribute.
func (a NominationAttribute) String() string {
return fmt.Sprintf("NOMINATION: %d", a.Value)
}
// Nomination creates a new STUN nomination attribute.
func Nomination(value uint32) NominationAttribute {
return NominationAttribute{Value: value}
}
// NominationSetter is a STUN setter for nomination attribute with configurable type.
type NominationSetter struct {
Value uint32
AttrType stun.AttrType
}
// AddTo adds a Nomination attribute to a STUN message using the configured attribute type.
func (n NominationSetter) AddTo(m *stun.Message) error {
attr := NominationAttribute{Value: n.Value}
return attr.AddToWithType(m, n.AttrType)
}

906
renomination_test.go Normal file
View File

@@ -0,0 +1,906 @@
// SPDX-FileCopyrightText: 2025 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
// +build !js
package ice
import (
"net"
"testing"
"time"
"github.com/pion/ice/v4/internal/fakenet"
"github.com/pion/stun/v3"
"github.com/stretchr/testify/assert"
)
const (
testLocalUfrag = "localufrag"
testLocalPwd = "localpwd"
testRemoteUfrag = "remoteufrag"
testRemotePwd = "remotepwd"
)
// Mock packet conn that captures sent packets.
type mockPacketConnWithCapture struct {
sentPackets [][]byte
sentAddrs []net.Addr
}
func (m *mockPacketConnWithCapture) ReadFrom([]byte) (n int, addr net.Addr, err error) {
return 0, nil, nil
}
func (m *mockPacketConnWithCapture) WriteTo(b []byte, addr net.Addr) (n int, err error) {
// Capture the packet
packet := make([]byte, len(b))
copy(packet, b)
m.sentPackets = append(m.sentPackets, packet)
m.sentAddrs = append(m.sentAddrs, addr)
return len(b), nil
}
func (m *mockPacketConnWithCapture) Close() error { return nil }
func (m *mockPacketConnWithCapture) LocalAddr() net.Addr { return nil }
func (m *mockPacketConnWithCapture) SetDeadline(time.Time) error { return nil }
func (m *mockPacketConnWithCapture) SetReadDeadline(time.Time) error { return nil }
func (m *mockPacketConnWithCapture) SetWriteDeadline(time.Time) error { return nil }
// createRenominationTestAgent creates a test agent with renomination enabled and returns local/remote candidates.
func createRenominationTestAgent(t *testing.T, controlling bool) (*Agent, Candidate, Candidate) {
t.Helper()
agent, err := NewAgentWithOptions(WithRenomination(func() uint32 { return 1 }))
assert.NoError(t, err)
agent.isControlling.Store(controlling)
local, err := NewCandidateHost(&CandidateHostConfig{
Network: "udp",
Address: "127.0.0.1",
Port: 12345,
Component: 1,
})
assert.NoError(t, err)
remote, err := NewCandidateHost(&CandidateHostConfig{
Network: "udp",
Address: "127.0.0.1",
Port: 54321,
Component: 1,
})
assert.NoError(t, err)
return agent, local, remote
}
func TestNominationAttribute(t *testing.T) {
t.Run("AddTo and GetFrom", func(t *testing.T) {
m := &stun.Message{}
attr := NominationAttribute{Value: 0x123456}
err := attr.AddTo(m)
assert.NoError(t, err)
var parsed NominationAttribute
err = parsed.GetFrom(m)
assert.NoError(t, err)
assert.Equal(t, uint32(0x123456), parsed.Value)
})
t.Run("24-bit value boundary", func(t *testing.T) {
m := &stun.Message{}
maxValue := uint32((1 << 24) - 1) // 24-bit max value
attr := NominationAttribute{Value: maxValue}
err := attr.AddTo(m)
assert.NoError(t, err)
var parsed NominationAttribute
err = parsed.GetFrom(m)
assert.NoError(t, err)
assert.Equal(t, maxValue, parsed.Value)
})
t.Run("String representation", func(t *testing.T) {
attr := NominationAttribute{Value: 12345}
str := attr.String()
assert.Contains(t, str, "NOMINATION")
assert.Contains(t, str, "12345")
})
t.Run("Nomination helper function", func(t *testing.T) {
attr := Nomination(42)
assert.Equal(t, uint32(42), attr.Value)
})
}
func TestRenominationConfiguration(t *testing.T) {
nominationCounter := uint32(0)
agent, err := NewAgentWithOptions(WithRenomination(func() uint32 {
nominationCounter++
return nominationCounter
}))
assert.NoError(t, err)
defer func() {
assert.NoError(t, agent.Close())
}()
assert.True(t, agent.enableRenomination)
assert.NotNil(t, agent.nominationValueGenerator)
// Test nomination value generation
value1 := agent.nominationValueGenerator()
value2 := agent.nominationValueGenerator()
assert.Equal(t, uint32(1), value1)
assert.Equal(t, uint32(2), value2)
}
func TestControlledSelectorNominationAcceptance(t *testing.T) {
agent, err := NewAgentWithOptions(WithRenomination(DefaultNominationValueGenerator()))
assert.NoError(t, err)
defer func() {
assert.NoError(t, agent.Close())
}()
selector := &controlledSelector{
agent: agent,
log: agent.log,
}
selector.Start()
// First nomination should be accepted
nomination1 := uint32(5)
assert.True(t, selector.shouldAcceptNomination(&nomination1))
// Higher nomination should be accepted
nomination2 := uint32(10)
assert.True(t, selector.shouldAcceptNomination(&nomination2))
// Lower nomination should be rejected
nomination3 := uint32(7)
assert.False(t, selector.shouldAcceptNomination(&nomination3))
// Equal nomination should be rejected
nomination4 := uint32(10)
assert.False(t, selector.shouldAcceptNomination(&nomination4))
// Nil nomination should be accepted (standard ICE)
assert.True(t, selector.shouldAcceptNomination(nil))
}
func TestControlledSelectorNominationDisabled(t *testing.T) {
config := &AgentConfig{
// Renomination disabled by default
}
agent, err := NewAgent(config)
assert.NoError(t, err)
defer func() {
assert.NoError(t, agent.Close())
}()
selector := &controlledSelector{
agent: agent,
log: agent.log,
}
selector.Start()
// Standard ICE nomination (no value) should be accepted
assert.True(t, selector.shouldAcceptNomination(nil))
// When controlling side uses renomination (sends nomination values),
// controlled side should apply "last nomination wins" regardless of local config
nomination1 := uint32(5)
assert.True(t, selector.shouldAcceptNomination(&nomination1))
nomination2 := uint32(3) // Lower value should be rejected
assert.False(t, selector.shouldAcceptNomination(&nomination2))
nomination3 := uint32(8) // Higher value should be accepted
assert.True(t, selector.shouldAcceptNomination(&nomination3))
}
func TestAgentRenominateCandidate(t *testing.T) {
t.Run("controlling agent can renominate", func(t *testing.T) {
nominationCounter := uint32(0)
agent, err := NewAgentWithOptions(WithRenomination(func() uint32 {
nominationCounter++
return nominationCounter
}))
assert.NoError(t, err)
defer func() {
assert.NoError(t, agent.Close())
}()
// Set up credentials for STUN authentication
agent.localUfrag = testLocalUfrag
agent.localPwd = testLocalPwd
agent.remoteUfrag = testRemoteUfrag
agent.remotePwd = testRemotePwd
// Set agent as controlling
agent.isControlling.Store(true)
// Create test candidates with mock connection
local, err := NewCandidateHost(&CandidateHostConfig{
Network: "udp",
Address: "127.0.0.1",
Port: 12345,
Component: 1,
})
assert.NoError(t, err)
remote, err := NewCandidateHost(&CandidateHostConfig{
Network: "udp",
Address: "127.0.0.1",
Port: 54321,
Component: 1,
})
assert.NoError(t, err)
// Mock the connection for the local candidate to avoid nil pointer
mockConn := &fakenet.MockPacketConn{}
local.conn = mockConn
// Add pair to agent
pair := agent.addPair(local, remote)
pair.state = CandidatePairStateSucceeded
// Test renomination
err = agent.RenominateCandidate(local, remote)
assert.NoError(t, err)
})
t.Run("non-controlling agent cannot renominate", func(t *testing.T) {
agent, local, remote := createRenominationTestAgent(t, false)
defer func() {
assert.NoError(t, agent.Close())
}()
err := agent.RenominateCandidate(local, remote)
assert.Error(t, err)
assert.Contains(t, err.Error(), "only controlling agent can renominate")
})
t.Run("renomination when disabled", func(t *testing.T) {
config := &AgentConfig{
// Renomination disabled by default
}
agent, err := NewAgent(config)
assert.NoError(t, err)
defer func() {
assert.NoError(t, agent.Close())
}()
agent.isControlling.Store(true)
local, err := NewCandidateHost(&CandidateHostConfig{
Network: "udp",
Address: "127.0.0.1",
Port: 12345,
Component: 1,
})
assert.NoError(t, err)
remote, err := NewCandidateHost(&CandidateHostConfig{
Network: "udp",
Address: "127.0.0.1",
Port: 54321,
Component: 1,
})
assert.NoError(t, err)
err = agent.RenominateCandidate(local, remote)
assert.Error(t, err)
assert.Contains(t, err.Error(), "renomination is not enabled")
})
t.Run("renomination with non-existent candidate pair", func(t *testing.T) {
agent, local, remote := createRenominationTestAgent(t, true)
defer func() {
assert.NoError(t, agent.Close())
}()
// Don't add pair to agent - should fail
err := agent.RenominateCandidate(local, remote)
assert.Error(t, err)
assert.Contains(t, err.Error(), "candidate pair not found")
})
}
func TestSendNominationRequest(t *testing.T) {
t.Run("STUN message contains nomination attribute", func(t *testing.T) {
nominationCounter := uint32(0)
agent, err := NewAgentWithOptions(WithRenomination(func() uint32 {
nominationCounter++
return nominationCounter
}))
assert.NoError(t, err)
defer func() {
assert.NoError(t, agent.Close())
}()
// Set up credentials for STUN authentication
agent.localUfrag = testLocalUfrag
agent.localPwd = testLocalPwd
agent.remoteUfrag = testRemoteUfrag
agent.remotePwd = testRemotePwd
agent.isControlling.Store(true)
// Create test candidates
local, err := NewCandidateHost(&CandidateHostConfig{
Network: "udp",
Address: "127.0.0.1",
Port: 12345,
Component: 1,
})
assert.NoError(t, err)
remote, err := NewCandidateHost(&CandidateHostConfig{
Network: "udp",
Address: "127.0.0.1",
Port: 54321,
Component: 1,
})
assert.NoError(t, err)
// Mock connection to capture sent messages
mockConn := &mockPacketConnWithCapture{}
local.conn = mockConn
pair := agent.addPair(local, remote)
pair.state = CandidatePairStateSucceeded
// Test sendNominationRequest directly
nominationValue := uint32(123)
err = agent.sendNominationRequest(pair, nominationValue)
assert.NoError(t, err)
// Verify message was sent
assert.True(t, len(mockConn.sentPackets) > 0)
// Parse the sent STUN message
msg := &stun.Message{}
err = msg.UnmarshalBinary(mockConn.sentPackets[0])
assert.NoError(t, err)
// Verify it's a binding request
assert.True(t, msg.Type.Method == stun.MethodBinding)
assert.True(t, msg.Type.Class == stun.ClassRequest)
// Verify USE-CANDIDATE is present
assert.True(t, msg.Contains(stun.AttrUseCandidate))
// Verify nomination attribute is present
var nomination NominationAttribute
err = nomination.GetFrom(msg)
assert.NoError(t, err)
assert.Equal(t, nominationValue, nomination.Value)
})
t.Run("STUN message without nomination when disabled", func(t *testing.T) {
config := &AgentConfig{
// Renomination disabled by default
}
agent, err := NewAgent(config)
assert.NoError(t, err)
defer func() {
assert.NoError(t, agent.Close())
}()
// Set up credentials
agent.localUfrag = testLocalUfrag
agent.localPwd = testLocalPwd
agent.remoteUfrag = testRemoteUfrag
agent.remotePwd = testRemotePwd
agent.isControlling.Store(true)
local, err := NewCandidateHost(&CandidateHostConfig{
Network: "udp",
Address: "127.0.0.1",
Port: 12345,
Component: 1,
})
assert.NoError(t, err)
remote, err := NewCandidateHost(&CandidateHostConfig{
Network: "udp",
Address: "127.0.0.1",
Port: 54321,
Component: 1,
})
assert.NoError(t, err)
mockConn := &mockPacketConnWithCapture{}
local.conn = mockConn
pair := agent.addPair(local, remote)
// Send nomination with value 0 (should not include nomination attribute)
err = agent.sendNominationRequest(pair, 0)
assert.NoError(t, err)
// Parse the sent message
msg := &stun.Message{}
err = msg.UnmarshalBinary(mockConn.sentPackets[0])
assert.NoError(t, err)
// Verify USE-CANDIDATE is present
assert.True(t, msg.Contains(stun.AttrUseCandidate))
// Verify nomination attribute is NOT present
var nomination NominationAttribute
err = nomination.GetFrom(msg)
assert.Error(t, err) // Should fail since attribute is not present
})
}
func TestRenominationErrorCases(t *testing.T) {
t.Run("getNominationValue with nil generator", func(t *testing.T) {
// Try to create agent with nil generator - should fail
_, err := NewAgentWithOptions(WithRenomination(nil))
assert.ErrorIs(t, err, ErrInvalidNominationValueGenerator)
// Create agent without renomination for testing
agent, err := NewAgentWithOptions()
assert.NoError(t, err)
defer func() {
assert.NoError(t, agent.Close())
}()
// Should return 0 when no generator is set
value := agent.getNominationValue()
assert.Equal(t, uint32(0), value)
})
t.Run("STUN message build with invalid attributes", func(t *testing.T) {
agent, err := NewAgentWithOptions(WithRenomination(func() uint32 { return 1 }))
assert.NoError(t, err)
defer func() {
assert.NoError(t, agent.Close())
}()
// Set up minimal credentials but missing remote password
agent.localUfrag = "localufrag"
agent.localPwd = "localpwd"
agent.remoteUfrag = "remoteufrag"
// agent.remotePwd = "" // Missing remote password
agent.isControlling.Store(true)
local, err := NewCandidateHost(&CandidateHostConfig{
Network: "udp",
Address: "127.0.0.1",
Port: 12345,
Component: 1,
})
assert.NoError(t, err)
remote, err := NewCandidateHost(&CandidateHostConfig{
Network: "udp",
Address: "127.0.0.1",
Port: 54321,
Component: 1,
})
assert.NoError(t, err)
mockConn := &mockPacketConnWithCapture{}
local.conn = mockConn
pair := agent.addPair(local, remote)
// This should succeed even with missing remote password
// as the STUN library will still build the message
err = agent.sendNominationRequest(pair, 1)
assert.NoError(t, err)
})
}
func TestNominationValueBoundaries(t *testing.T) {
t.Run("24-bit maximum value", func(t *testing.T) {
maxValue := uint32((1 << 24) - 1) // 0xFFFFFF
attr := NominationAttribute{Value: maxValue}
m := &stun.Message{}
err := attr.AddTo(m)
assert.NoError(t, err)
var parsed NominationAttribute
err = parsed.GetFrom(m)
assert.NoError(t, err)
assert.Equal(t, maxValue, parsed.Value)
})
t.Run("zero nomination value", func(t *testing.T) {
attr := NominationAttribute{Value: 0}
m := &stun.Message{}
err := attr.AddTo(m)
assert.NoError(t, err)
var parsed NominationAttribute
err = parsed.GetFrom(m)
assert.NoError(t, err)
assert.Equal(t, uint32(0), parsed.Value)
})
t.Run("nomination value overflow", func(t *testing.T) {
// Test value larger than 24-bit
overflowValue := uint32(1 << 25) // Larger than 24-bit max
attr := NominationAttribute{Value: overflowValue}
m := &stun.Message{}
err := attr.AddTo(m)
assert.NoError(t, err)
var parsed NominationAttribute
err = parsed.GetFrom(m)
assert.NoError(t, err)
// Should be truncated to 24-bit value
expectedValue := overflowValue & 0xFFFFFF
assert.Equal(t, expectedValue, parsed.Value)
})
t.Run("invalid attribute size", func(t *testing.T) {
m := &stun.Message{}
// Add a nomination attribute with invalid size (too short)
m.Add(DefaultNominationAttribute, []byte{0x01, 0x02}) // Only 2 bytes instead of 4
var nomination NominationAttribute
err := nomination.GetFrom(m)
assert.Error(t, err)
assert.Equal(t, stun.ErrAttributeSizeInvalid, err)
})
t.Run("configurable nomination attribute type", func(t *testing.T) {
// Test with custom attribute type
customAttrType := stun.AttrType(0x0040)
attr := NominationAttribute{Value: 12345}
m := &stun.Message{}
err := attr.AddToWithType(m, customAttrType)
assert.NoError(t, err)
// Try to read with default type - should fail
var parsed1 NominationAttribute
err = parsed1.GetFrom(m)
assert.Error(t, err)
// Read with custom type - should succeed
var parsed2 NominationAttribute
err = parsed2.GetFromWithType(m, customAttrType)
assert.NoError(t, err)
assert.Equal(t, uint32(12345), parsed2.Value)
})
t.Run("NominationSetter with custom attribute type", func(t *testing.T) {
customAttrType := stun.AttrType(0x0050)
setter := NominationSetter{
Value: 98765,
AttrType: customAttrType,
}
m := &stun.Message{}
err := setter.AddTo(m)
assert.NoError(t, err)
// Verify the attribute was added with custom type
var parsed NominationAttribute
err = parsed.GetFromWithType(m, customAttrType)
assert.NoError(t, err)
assert.Equal(t, uint32(98765), parsed.Value)
// Verify it wasn't added with default type
var parsedDefault NominationAttribute
err = parsedDefault.GetFrom(m)
assert.Error(t, err)
})
}
func TestControlledSelectorWithActualSTUNMessages(t *testing.T) {
t.Run("HandleBindingRequest with nomination attribute", func(t *testing.T) {
agent, err := NewAgentWithOptions(WithRenomination(DefaultNominationValueGenerator()))
assert.NoError(t, err)
defer func() {
assert.NoError(t, agent.Close())
}()
// Set up credentials for STUN
agent.localUfrag = testLocalUfrag
agent.localPwd = testLocalPwd
agent.remoteUfrag = testRemoteUfrag
agent.remotePwd = testRemotePwd
selector := &controlledSelector{
agent: agent,
log: agent.log,
}
selector.Start()
// Create test candidates
local, err := NewCandidateHost(&CandidateHostConfig{
Network: "udp",
Address: "127.0.0.1",
Port: 12345,
Component: 1,
})
assert.NoError(t, err)
remote, err := NewCandidateHost(&CandidateHostConfig{
Network: "udp",
Address: "127.0.0.1",
Port: 54321,
Component: 1,
})
assert.NoError(t, err)
// Mock connection for response
mockConn := &mockPacketConnWithCapture{}
local.conn = mockConn
// Create STUN binding request with nomination and USE-CANDIDATE
msg, err := stun.Build(
stun.BindingRequest,
stun.TransactionID,
stun.NewUsername(agent.localUfrag+":"+agent.remoteUfrag),
UseCandidate(),
Nomination(5), // First nomination value
stun.NewShortTermIntegrity(agent.localPwd),
stun.Fingerprint,
)
assert.NoError(t, err)
// Handle the binding request
selector.HandleBindingRequest(msg, local, remote)
// Verify selector accepted the nomination
assert.NotNil(t, selector.lastNomination)
assert.Equal(t, uint32(5), *selector.lastNomination)
// Create another STUN request with higher nomination value
msg2, err := stun.Build(
stun.BindingRequest,
stun.TransactionID,
stun.NewUsername(agent.localUfrag+":"+agent.remoteUfrag),
UseCandidate(),
Nomination(10), // Higher nomination value
stun.NewShortTermIntegrity(agent.localPwd),
stun.Fingerprint,
)
assert.NoError(t, err)
// Handle the second binding request
selector.HandleBindingRequest(msg2, local, remote)
// Should accept higher nomination
assert.Equal(t, uint32(10), *selector.lastNomination)
// Create another STUN request with lower nomination value
msg3, err := stun.Build(
stun.BindingRequest,
stun.TransactionID,
stun.NewUsername(agent.localUfrag+":"+agent.remoteUfrag),
UseCandidate(),
Nomination(7), // Lower nomination value
stun.NewShortTermIntegrity(agent.localPwd),
stun.Fingerprint,
)
assert.NoError(t, err)
// Handle the third binding request
selector.HandleBindingRequest(msg3, local, remote)
// Should reject lower nomination (lastNomination should remain 10)
assert.Equal(t, uint32(10), *selector.lastNomination)
})
t.Run("HandleBindingRequest without nomination attribute", func(t *testing.T) {
agent, err := NewAgentWithOptions(WithRenomination(DefaultNominationValueGenerator()))
assert.NoError(t, err)
defer func() {
assert.NoError(t, agent.Close())
}()
agent.localUfrag = testLocalUfrag
agent.localPwd = testLocalPwd
agent.remoteUfrag = testRemoteUfrag
agent.remotePwd = testRemotePwd
selector := &controlledSelector{
agent: agent,
log: agent.log,
}
selector.Start()
local, err := NewCandidateHost(&CandidateHostConfig{
Network: "udp",
Address: "127.0.0.1",
Port: 12345,
Component: 1,
})
assert.NoError(t, err)
remote, err := NewCandidateHost(&CandidateHostConfig{
Network: "udp",
Address: "127.0.0.1",
Port: 54321,
Component: 1,
})
assert.NoError(t, err)
mockConn := &mockPacketConnWithCapture{}
local.conn = mockConn
// Create STUN binding request without nomination (standard ICE)
msg, err := stun.Build(
stun.BindingRequest,
stun.TransactionID,
stun.NewUsername(agent.localUfrag+":"+agent.remoteUfrag),
UseCandidate(),
// No nomination attribute
stun.NewShortTermIntegrity(agent.localPwd),
stun.Fingerprint,
)
assert.NoError(t, err)
// Handle the binding request
selector.HandleBindingRequest(msg, local, remote)
// Without nomination attribute, lastNomination should remain nil
assert.Nil(t, selector.lastNomination)
})
}
func TestInvalidRenominationConfig(t *testing.T) {
t.Run("nil nomination generator with renomination enabled", func(t *testing.T) {
config := &AgentConfig{}
// Without renomination, agent should work fine
agent, err := NewAgent(config)
assert.NoError(t, err)
defer func() {
assert.NoError(t, agent.Close())
}()
// Agent should be created successfully without renomination
assert.False(t, agent.enableRenomination)
assert.Nil(t, agent.nominationValueGenerator)
// getNominationValue should return 0
value := agent.getNominationValue()
assert.Equal(t, uint32(0), value)
})
t.Run("different generator behaviors", func(t *testing.T) {
// Test constant generator
agent1, err := NewAgentWithOptions(WithRenomination(func() uint32 { return 42 }))
assert.NoError(t, err)
defer func() {
assert.NoError(t, agent1.Close())
}()
value1 := agent1.getNominationValue()
value2 := agent1.getNominationValue()
assert.Equal(t, uint32(42), value1)
assert.Equal(t, uint32(42), value2)
// Test incrementing generator
counter := uint32(0)
agent2, err := NewAgentWithOptions(WithRenomination(func() uint32 {
counter++
return counter
}))
assert.NoError(t, err)
defer func() {
assert.NoError(t, agent2.Close())
}()
value3 := agent2.getNominationValue()
value4 := agent2.getNominationValue()
assert.Equal(t, uint32(1), value3)
assert.Equal(t, uint32(2), value4)
})
t.Run("controlled agent handles renomination regardless of local config", func(t *testing.T) {
// Create controlled agent with renomination DISABLED
controlledAgent, err := NewAgent(&AgentConfig{
// Renomination disabled by default // Disabled locally
})
assert.NoError(t, err)
defer func() {
assert.NoError(t, controlledAgent.Close())
}()
// Set up as controlled (non-controlling)
controlledAgent.isControlling.Store(false)
// Create controlled selector to test nomination handling
selector := &controlledSelector{
agent: controlledAgent,
log: controlledAgent.log,
}
// Test 1: Should accept nomination without value (standard ICE)
assert.True(t, selector.shouldAcceptNomination(nil))
// Test 2: Should accept first nomination with value (renomination from controlling side)
value1 := uint32(1)
assert.True(t, selector.shouldAcceptNomination(&value1))
assert.Equal(t, &value1, selector.lastNomination)
// Test 3: Should accept higher nomination value
value2 := uint32(2)
assert.True(t, selector.shouldAcceptNomination(&value2))
assert.Equal(t, &value2, selector.lastNomination)
// Test 4: Should reject lower nomination value
value0 := uint32(0)
assert.False(t, selector.shouldAcceptNomination(&value0))
assert.Equal(t, &value2, selector.lastNomination) // Should remain unchanged
})
}
func TestAgentWithCustomNominationAttribute(t *testing.T) {
t.Run("agent uses custom nomination attribute with option", func(t *testing.T) {
customAttr := uint16(0x0042)
// Create agent with custom nomination attribute using option
agent, err := NewAgentWithOptions(
WithRenomination(func() uint32 { return 100 }),
WithNominationAttribute(customAttr),
)
assert.NoError(t, err)
defer agent.Close() //nolint:errcheck
// Verify the agent has the custom attribute configured
assert.Equal(t, stun.AttrType(customAttr), agent.nominationAttribute)
})
t.Run("agent uses default nomination attribute when not configured", func(t *testing.T) {
// Create agent without custom nomination attribute
agentConfig := &AgentConfig{
NetworkTypes: []NetworkType{NetworkTypeUDP4},
}
agent, err := NewAgent(agentConfig)
assert.NoError(t, err)
defer agent.Close() //nolint:errcheck
// Verify the agent has the default attribute
assert.Equal(t, stun.AttrType(0x0030), agent.nominationAttribute)
})
t.Run("multiple options can be applied", func(t *testing.T) {
customAttr := uint16(0x0055)
// Test that multiple options can be applied
agent, err := NewAgentWithOptions(
WithRenomination(func() uint32 { return 200 }),
WithNominationAttribute(customAttr),
)
assert.NoError(t, err)
defer agent.Close() //nolint:errcheck
assert.Equal(t, stun.AttrType(customAttr), agent.nominationAttribute)
})
t.Run("WithNominationAttribute returns error for invalid value", func(t *testing.T) {
// Test that 0x0000 is rejected as invalid
_, err := NewAgentWithOptions(WithNominationAttribute(0x0000))
assert.ErrorIs(t, err, ErrInvalidNominationAttribute)
})
}

View File

@@ -188,11 +188,35 @@ func (s *controllingSelector) PingCandidate(local, remote Candidate) {
}
type controlledSelector struct {
agent *Agent
log logging.LeveledLogger
agent *Agent
log logging.LeveledLogger
lastNomination *uint32 // For renomination: tracks highest nomination value seen
}
func (s *controlledSelector) Start() {
s.lastNomination = nil
}
// shouldAcceptNomination checks if a nomination should be accepted based on renomination rules.
func (s *controlledSelector) shouldAcceptNomination(nominationValue *uint32) bool {
// If no nomination value, accept normally (standard ICE nomination)
if nominationValue == nil {
return true
}
// If nomination value is present, controlling side is using renomination
// Apply "last nomination wins" rule
if s.lastNomination == nil || *nominationValue > *s.lastNomination {
s.lastNomination = nominationValue
s.log.Tracef("Accepting nomination with value %d", *nominationValue)
return true
}
s.log.Tracef("Rejecting nomination value %d (current is %d)", *nominationValue, *s.lastNomination)
return false
}
func (s *controlledSelector) ContactCandidates() {
@@ -288,6 +312,22 @@ func (s *controlledSelector) HandleBindingRequest(message *stun.Message, local,
if message.Contains(stun.AttrUseCandidate) { //nolint:nestif
// https://tools.ietf.org/html/rfc8445#section-7.3.1.5
// Check for renomination attribute
var nominationValue *uint32
var nomination NominationAttribute
if err := nomination.GetFromWithType(message, s.agent.nominationAttribute); err == nil {
nominationValue = &nomination.Value
s.log.Tracef("Received nomination with value %d", nomination.Value)
}
// Check if we should accept this nomination based on renomination rules
if !s.shouldAcceptNomination(nominationValue) {
s.log.Tracef("Rejecting nomination request due to renomination rules")
s.agent.sendBindingSuccess(message, local, remote)
return
}
if pair.state == CandidatePairStateSucceeded {
// If the state of this pair is Succeeded, it means that the check
// previously sent by this pair produced a successful response and
@@ -298,6 +338,7 @@ func (s *controlledSelector) HandleBindingRequest(message *stun.Message, local,
(selectedPair != pair &&
(!s.agent.needsToCheckPriorityOnNominated() ||
selectedPair.priority() <= pair.priority())) {
s.log.Tracef("Accepting nomination for pair %s", pair)
s.agent.setSelectedPair(pair)
} else if selectedPair != pair {
s.log.Tracef("Ignore nominate new pair %s, already nominated pair %s", pair, selectedPair)