Skip to content
Open
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
136 changes: 129 additions & 7 deletions agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,17 @@ type Agent struct {
lastRenominationTime time.Time

turnClientFactory func(*turn.ClientConfig) (turnClient, error)

// How long consent remains valid without an authenticated non-error STUN Binding response.
consentFreshnessTimeout time.Duration

// Timestamp of the last consent refresh for the selected candidate pair.
lastConsentAt time.Time

// Callback that allows user to send an error response for inbound STUN Binding Requests before success is emitted.
// Returning nil continues normal success handling.
userBindingReqErrorRespHandler func(
m *stun.Message, local, remote Candidate, pair *CandidatePair) *BindingRequestErrorResponse
}

// NewAgent creates a new Agent.
Expand Down Expand Up @@ -378,6 +389,8 @@ func createAgentBase(config *AgentConfig) (*Agent, error) {
automaticRenomination: false,
renominationInterval: 3 * time.Second, // Default matching libwebrtc
turnClientFactory: defaultTurnClient,
userBindingReqErrorRespHandler: config.BindingRequestErrorResponseHandler,
consentFreshnessTimeout: defaultConsentFreshnessTimeout,
}

config.initWithDefaults(agent)
Expand Down Expand Up @@ -750,13 +763,15 @@ func (a *Agent) setSelectedPair(pair *CandidatePair) {
if pair == nil {
var nilPair *CandidatePair
a.selectedPair.Store(nilPair)
a.lastConsentAt = time.Time{}
a.log.Tracef("Unset selected candidate pair")

return
}

pair.nominated = true
a.selectedPair.Store(pair)
a.lastConsentAt = time.Now()
a.log.Tracef("Set selected candidate pair: %s", pair)

// Signal connected: notify any Connect() calls waiting on onConnected
Expand All @@ -769,6 +784,15 @@ func (a *Agent) setSelectedPair(pair *CandidatePair) {
a.selectedCandidatePairNotifier.EnqueueSelectedCandidatePair(pair)
}

// consentExpired checks if the consent freshness has expired for the selected candidate pair.
func (a *Agent) consentExpired(now time.Time) bool {
if a.consentFreshnessTimeout <= 0 || a.lastConsentAt.IsZero() {
return false
}

return now.Sub(a.lastConsentAt) > a.consentFreshnessTimeout
}

func (a *Agent) pingAllCandidates() {
a.log.Trace("Pinging all candidates")

Expand Down Expand Up @@ -885,6 +909,14 @@ func (a *Agent) validateSelectedPair() bool {
return false
}

now := time.Now()
if a.consentExpired(now) {
a.log.Warnf("Consent expired for selected pair after %v without valid response", a.consentFreshnessTimeout)
a.updateConnectionState(ConnectionStateFailed)

return false
}
Comment on lines +912 to +918

Copilot AI Mar 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConsentFreshnessTimeout is enforced via validateSelectedPair(), but the connectivity check ticker interval isn't constrained by consentFreshnessTimeout. If a user configures a consent timeout smaller than the keepalive/check/disconnected/failed intervals, consent expiry may be detected late. Consider factoring consentFreshnessTimeout into the connectivityChecks() timer interval selection (similar to disconnectedTimeout/failedTimeout) so validateSelectedPair() runs often enough to enforce the configured timeout.

Copilot uses AI. Check for mistakes.

disconnectedTime := time.Since(selectedPair.Remote.LastReceived())

// Only allow transitions to failed if a.failedTimeout is non-zero
Expand Down Expand Up @@ -1412,6 +1444,28 @@ func (a *Agent) sendBindingSuccess(m *stun.Message, local, remote Candidate) {
}
}

func (a *Agent) sendBindingError(
m *stun.Message, local, remote Candidate, bindingErrorResponse BindingRequestErrorResponse,
) {
setters := make([]stun.Setter, 0, len(bindingErrorResponse.ExtraAttributes)+5)
setters = append(setters, m, stun.BindingError)
setters = append(setters, bindingErrorResponse.ErrorCodeAttribute)
setters = append(setters, bindingErrorResponse.ExtraAttributes...)
setters = append(setters,
stun.NewShortTermIntegrity(a.localPwd),
stun.Fingerprint,
)

if out, err := stun.Build(setters...); err != nil {
a.log.Warnf("Failed to send binding error response from: %s to: %s error: %s", local, remote, err)
} else {
if pair := a.findPair(local, remote); pair != nil {
pair.UpdateResponseSent()
}
a.sendSTUN(out, local, remote)
}
}

// Removes pending binding requests that are over maxBindingRequestTimeout old
//
// Let HTO be the transaction timeout, which SHOULD be 2*RTT if
Expand All @@ -1433,12 +1487,19 @@ func (a *Agent) invalidatePendingBindingRequests(filterTime time.Time) {
}
}

// Assert that the passed TransactionID is in our pendingBindingRequests and returns the destination
// If the bindingRequest was valid remove it from our pending cache.
func (a *Agent) handleInboundBindingSuccess(id [stun.TransactionIDSize]byte) (bool, *bindingRequest, time.Duration) {
// consumePendingBindingRequest validates that the passed TransactionID and remote address match a pending binding
// request. If a match is found, the binding request is removed from the pending cache and returned along with how
// long ago it was sent. If no match is found, nil is returned.
func (a *Agent) consumePendingBindingRequest(
id [stun.TransactionIDSize]byte, remoteAddr net.Addr,
) (bool, *bindingRequest, time.Duration) {
a.invalidatePendingBindingRequests(time.Now())
for i := range a.pendingBindingRequests {
if a.pendingBindingRequests[i].transactionID == id {
if !addrEqual(a.pendingBindingRequests[i].destination, remoteAddr) {
return false, nil, 0
}

validBindingRequest := a.pendingBindingRequests[i]
a.pendingBindingRequests = append(a.pendingBindingRequests[:i], a.pendingBindingRequests[i+1:]...)

Expand Down Expand Up @@ -1481,7 +1542,7 @@ func (a *Agent) handleRoleConflict(msg *stun.Message, local, remote Candidate, r
}

// handleInbound processes STUN traffic from a remote candidate.
func (a *Agent) handleInbound(msg *stun.Message, local Candidate, remote net.Addr) {
func (a *Agent) handleInbound(msg *stun.Message, local Candidate, remote net.Addr) { // nolint:cyclop
if msg == nil || local == nil {
return
}
Expand All @@ -1504,6 +1565,10 @@ func (a *Agent) handleInbound(msg *stun.Message, local Candidate, remote net.Add
if remoteCandidate, ok = a.handleInboundRequest(remoteCandidate, local, remote, msg); !ok {
return
}
case stun.ClassErrorResponse:
if !a.handleInboundErrorResponse(remoteCandidate, local, remote, msg) {
return
}
default:
}

Expand All @@ -1516,7 +1581,8 @@ func canHandleInbound(msg *stun.Message) bool {
return msg.Type.Method == stun.MethodBinding &&
(msg.Type.Class == stun.ClassSuccessResponse ||
msg.Type.Class == stun.ClassRequest ||
msg.Type.Class == stun.ClassIndication)
msg.Type.Class == stun.ClassIndication ||
msg.Type.Class == stun.ClassErrorResponse)
}

func (a *Agent) handleInboundResponse(
Expand All @@ -1534,9 +1600,17 @@ func (a *Agent) handleInboundResponse(
return false
}

a.getSelector().HandleSuccessResponse(msg, local, remoteCandidate, remote)
handled := a.getSelector().handleSuccessResponse(msg, local, remoteCandidate, remote)

return true
if handled {
if selectedPair := a.getSelectedPair(); selectedPair != nil &&
selectedPair.Local.Equal(local) && selectedPair.Remote.Equal(remoteCandidate) {
// Note consent freshness
a.lastConsentAt = time.Now()
}
}

return handled
}

func (a *Agent) handleInboundRequest(
Expand Down Expand Up @@ -1602,6 +1676,54 @@ func (a *Agent) handleInboundRequest(
return remoteCandidate, true
}

func (a *Agent) handleInboundErrorResponse(
remoteCandidate, _ Candidate, remoteAddr net.Addr, msg *stun.Message,
) bool { // nolint:unparam
if err := stun.MessageIntegrity([]byte(a.remotePwd)).Check(msg); err != nil {
a.log.Warnf("Discard error response with broken integrity from (%s), %v", remoteAddr, err)

return false
}

if remoteCandidate == nil {
a.log.Warnf("Discard error response from (%s), no such remote", remoteAddr)

return false
}

ok, pendingRequest, _ := a.consumePendingBindingRequest(msg.TransactionID, remoteAddr)
if !ok {
a.log.Warnf("Discard error response from (%s), unknown TransactionID 0x%x or address mismatch",
remoteAddr, msg.TransactionID)

Copilot AI Mar 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleInboundErrorResponse removes the pending binding request via consumePendingBindingRequest before validating that the response came from the expected remote address (addrEqual). If the address check fails, the pending request has already been dropped, so a later valid response for the same transaction would be treated as unknown. Consider only removing the pending request after the address check passes (or add a non-destructive lookup before consuming).

Suggested change
// The response came from an unexpected address. Re-add the pending
// binding request so that a later valid response for the same
// transaction ID can still be processed correctly.
a.addPendingBindingRequest(
pendingRequest.transactionID,
pendingRequest.destination,
pendingRequest.isUseCandidate,
pendingRequest.nominationValue,
)

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed

return false
}
_ = pendingRequest

errorCode := stun.ErrorCodeAttribute{}
if err := errorCode.GetFrom(msg); err != nil {
a.log.Debugf("Discard error response from (%s), no valid ERROR-CODE attribute: %v", remoteAddr, err)

return false
}

// Return true after successfully validating and accounting for an error response that doesn't immediately fail
// the agent, and false for cases where the error response should be discarded or indicates a fatal condition that
// should fail the agent.
switch errorCode.Code {
case stun.CodeForbidden:
a.log.Warnf("Received authenticated STUN 403 (Forbidden); revoking consent for %s", remoteAddr)
a.updateConnectionState(ConnectionStateFailed)

return false

default:
a.log.Debugf("Received authenticated STUN error response %d from %s", errorCode.Code, remoteAddr)

return false
}
}

// validateNonSTUNTraffic processes non STUN traffic from a remote candidate,
// and returns true if it is an actual remote candidate.
func (a *Agent) validateNonSTUNTraffic(local Candidate, remote net.Addr) (Candidate, bool) {
Expand Down
33 changes: 33 additions & 0 deletions agent_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ const (

// maxBindingRequestTimeout is the wait time before binding requests can be deleted.
maxBindingRequestTimeout = 4000 * time.Millisecond

// defaultConsentFreshnessTimeout is the maximum time consent can remain valid
// without an authenticated, non-error STUN Binding response.
defaultConsentFreshnessTimeout = 30 * time.Second
)

func defaultCandidateTypes() []CandidateType {
Expand Down Expand Up @@ -226,6 +230,29 @@ type AgentConfig struct {
// switched to that irrespective of relative priority between current selected pair
// and priority of the pair being switched to.
EnableUseCandidateCheckPriority bool

// ConsentFreshnessTimeout determines how long consent remains valid without an authenticated,
// non-error STUN Binding response.
// When this is nil, it defaults to 30 seconds. A timeout of 0 disables consent freshness expiry.
ConsentFreshnessTimeout *time.Duration

// BindingRequestErrorResponseHandler allows applications to send an error response for individual
// inbound STUN Binding Requests before a success response is emitted.
// It can be used to implement consent revocation by returning a Binding Error 403 (Forbidden)
// response when the agent receives a binding request for an existing candidate pair.
// Returning nil continues normal handling and sends a success response.
// Returning a non-nil BindingRequestErrorResponse causes the agent to send an authenticated
// STUN Binding Error response with the provided code/reason and optional extra attributes.
// Note: pair is nil when the binding request will create a new pair.
BindingRequestErrorResponseHandler func(m *stun.Message, local, remote Candidate,
pair *CandidatePair) *BindingRequestErrorResponse
}

// BindingRequestErrorResponse defines the STUN Binding Error response emitted
// for an inbound STUN Binding Request.
type BindingRequestErrorResponse struct {
ErrorCodeAttribute stun.ErrorCodeAttribute
ExtraAttributes []stun.Setter
}

// initWithDefaults populates an agent and falls back to defaults if fields are unset.
Expand Down Expand Up @@ -290,6 +317,12 @@ func (config *AgentConfig) initWithDefaults(agent *Agent) { //nolint:cyclop
agent.keepaliveInterval = *config.KeepaliveInterval
}

if config.ConsentFreshnessTimeout == nil {
agent.consentFreshnessTimeout = defaultConsentFreshnessTimeout
} else {
agent.consentFreshnessTimeout = *config.ConsentFreshnessTimeout
}

if config.CheckInterval == nil {
agent.checkInterval = defaultCheckInterval
} else {
Expand Down
13 changes: 13 additions & 0 deletions agent_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func TestAgentConfig_initWithDefaults(t *testing.T) {
func(t *testing.T, result *Agent) {
t.Helper()
assert.Equal(t, result.relayAcceptanceMinWait, defaultRelayAcceptanceMinWait)
assert.Equal(t, result.consentFreshnessTimeout, defaultConsentFreshnessTimeout)
},
},
{
Expand Down Expand Up @@ -57,6 +58,18 @@ func TestAgentConfig_initWithDefaults(t *testing.T) {
assert.Equal(t, result.relayAcceptanceMinWait, relayAcceptanceMinWait)
},
},
{
"consent freshness timeout can be disabled",
func() *AgentConfig {
zero := time.Duration(0)

return &AgentConfig{ConsentFreshnessTimeout: &zero}
}(),
func(t *testing.T, result *Agent) {
t.Helper()
assert.Equal(t, time.Duration(0), result.consentFreshnessTimeout)
},
},
}

for _, test := range tests {
Expand Down
37 changes: 37 additions & 0 deletions agent_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -960,3 +960,40 @@ func WithLoggerFactory(loggerFactory logging.LoggerFactory) AgentOption {
return nil
}
}

// WithConsentFreshnessTimeout sets how long consent remains valid without an authenticated, non-error
// STUN Binding response.
// A timeout of 0 disables consent freshness expiry.
func WithConsentFreshnessTimeout(timeout time.Duration) AgentOption {
return func(a *Agent) error {
if a.constructed {
return ErrAgentOptionNotUpdatable
}

a.consentFreshnessTimeout = timeout

return nil
}
}

// WithBindingRequestErrorResponseHandler sets a handler to return an optional STUN Binding Error response
// for inbound STUN Binding Requests.
// It can be used to implement consent revocation by returning a Binding Error 403 (Forbidden) response
// when the agent receives a binding request for an existing candidate pair.
// Returning nil continues normal success handling.
// Returning a non-nil response sends the configured ERROR-CODE and any additional attributes included
// in ExtraAttributes.
// Note: pair is nil when the binding request will create a new pair.
func WithBindingRequestErrorResponseHandler(
handler func(m *stun.Message, local, remote Candidate, pair *CandidatePair) *BindingRequestErrorResponse,
) AgentOption {
return func(a *Agent) error {
if a.constructed {
return ErrAgentOptionNotUpdatable
}

a.userBindingReqErrorRespHandler = handler

return nil
}
}
Loading
Loading