Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 92 additions & 1 deletion agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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{}),
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 changes: 69 additions & 0 deletions agent_options.go
Original file line number Diff line number Diff line change
@@ -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 changes: 110 additions & 0 deletions agent_options_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
15 changes: 15 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading