Skip to content

Commit d43393c

Browse files
committed
Skip connectivity checks in lite mode
1 parent ffbe6d5 commit d43393c

2 files changed

Lines changed: 186 additions & 7 deletions

File tree

selection.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -435,11 +435,10 @@ func (s *controlledSelector) HandleBindingRequest(message *stun.Message, local,
435435
return
436436
}
437437

438-
if pair.state == CandidatePairStateSucceeded {
439-
// If the state of this pair is Succeeded, it means that the check
440-
// previously sent by this pair produced a successful response and
441-
// generated a valid pair (Section 7.2.5.3.2). The agent sets the
442-
// nominated flag value of the valid pair to true.
438+
if pair.state == CandidatePairStateSucceeded || s.agent.lite {
439+
// For full agents: pair reached Succeeded via a triggered check (RFC 8445 §7.3.1.5).
440+
// For lite agents: RFC 8445 §7.3.2 — the lite agent directly constructs the pair,
441+
// places it in the valid list, and sets the nominated flag; no triggered check needed.
443442
selectedPair := s.agent.getSelectedPair()
444443
if s.shouldSwitchSelectedPair(pair, selectedPair, nominationValue) {
445444
s.log.Tracef("Accepting nomination for pair %s", pair)
@@ -462,12 +461,13 @@ func (s *controlledSelector) HandleBindingRequest(message *stun.Message, local,
462461

463462
s.agent.sendBindingSuccess(message, local, remote)
464463

465-
// Only send a triggered check during ICE checking phase (RFC 8445 §7.3.1.4).
464+
// Lite agents only act as STUN servers and MUST NOT generate connectivity checks (RFC 8445 §7).
465+
// For full agents: only send a triggered check during ICE checking phase (RFC 8445 §7.3.1.4).
466466
// Once the pair is established (succeeded + selected), sending a triggered check
467467
// on every inbound request creates a ping-pong busy loop: the remote side responds
468468
// and sends its own request, which triggers another check here, repeating at 1/RTT.
469469
// After connection, consent freshness is maintained by checkKeepalive() on a timer.
470-
if pair.state != CandidatePairStateSucceeded || s.agent.getSelectedPair() == nil {
470+
if !s.agent.lite && (pair.state != CandidatePairStateSucceeded || s.agent.getSelectedPair() == nil) {
471471
s.PingCandidate(local, remote)
472472
}
473473

selection_test.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1491,3 +1491,182 @@ func TestControllingSideRenomination(t *testing.T) {
14911491
"Controlling agent should NOT switch with standard nomination when pair already selected")
14921492
})
14931493
}
1494+
1495+
// ---------------------------------------------------------------------------
1496+
// Lite mode tests
1497+
// ---------------------------------------------------------------------------
1498+
1499+
// TestLiteControlledSelector_NoPingCandidate verifies that a lite controlled
1500+
// agent NEVER sends triggered connectivity checks (PingCandidate), regardless
1501+
// of the pair state. Per RFC 8445 §7, a lite implementation only acts as a
1502+
// STUN server and does not generate connectivity checks.
1503+
func TestLiteControlledSelector_NoPingCandidate(t *testing.T) {
1504+
buildMsg := func(t *testing.T, a *Agent) *stun.Message {
1505+
t.Helper()
1506+
msg, err := stun.Build(stun.BindingRequest,
1507+
stun.TransactionID,
1508+
stun.NewUsername(a.localUfrag+":"+a.remoteUfrag),
1509+
stun.NewShortTermIntegrity(a.localPwd),
1510+
stun.Fingerprint,
1511+
)
1512+
require.NoError(t, err)
1513+
1514+
return msg
1515+
}
1516+
1517+
setupAgent := func(t *testing.T) (*Agent, *pingNoIOCand, *pingNoIOCand, *CandidatePair) {
1518+
t.Helper()
1519+
liteAgent := bareAgentForPing()
1520+
liteAgent.log = logging.NewDefaultLoggerFactory().NewLogger("test")
1521+
liteAgent.remoteUfrag = selectionTestRemoteUfrag
1522+
liteAgent.localUfrag = selectionTestLocalUfrag
1523+
liteAgent.remotePwd = selectionTestPassword
1524+
liteAgent.localPwd = selectionTestPassword
1525+
liteAgent.tieBreaker = 1
1526+
liteAgent.lite = true
1527+
liteAgent.isControlling.Store(false)
1528+
liteAgent.onConnected = make(chan struct{})
1529+
liteAgent.setSelector()
1530+
1531+
local := newPingNoIOCand()
1532+
local.candidateBase.networkType = NetworkTypeUDP4
1533+
local.candidateBase.resolvedAddr = &net.UDPAddr{IP: net.ParseIP("192.168.1.1"), Port: 10000}
1534+
1535+
remote := newPingNoIOCand()
1536+
remote.candidateBase.networkType = NetworkTypeUDP4
1537+
remote.candidateBase.resolvedAddr = &net.UDPAddr{IP: net.ParseIP("192.168.1.2"), Port: 20000}
1538+
1539+
pair := liteAgent.addPair(local, remote)
1540+
1541+
return liteAgent, local, remote, pair
1542+
}
1543+
1544+
t.Run("NoTriggeredCheckWhileChecking", func(t *testing.T) {
1545+
// Pair is in Waiting state (ICE checking phase). A full controlled agent
1546+
// would send a triggered check here; a lite one must not.
1547+
agent, local, remote, pair := setupAgent(t)
1548+
1549+
ls, ok := agent.getSelector().(*liteSelector)
1550+
require.True(t, ok, "expected liteSelector as top-level selector")
1551+
_, ok = ls.pairCandidateSelector.(*controlledSelector)
1552+
require.True(t, ok, "expected controlledSelector inside liteSelector")
1553+
1554+
sentBefore := pair.RequestsSent()
1555+
msg := buildMsg(t, agent)
1556+
1557+
for range 5 {
1558+
ls.HandleBindingRequest(msg, local, remote)
1559+
}
1560+
1561+
assert.Equal(t, sentBefore, pair.RequestsSent(),
1562+
"lite controlled agent must not send triggered checks during ICE checking")
1563+
})
1564+
1565+
t.Run("NoTriggeredCheckWhenSucceededAndSelected", func(t *testing.T) {
1566+
// Pair is Succeeded and selected. Even a full agent suppresses checks here,
1567+
// but we verify the lite path also stays clean.
1568+
agent, local, remote, pair := setupAgent(t)
1569+
pair.state = CandidatePairStateSucceeded
1570+
agent.setSelectedPair(pair)
1571+
1572+
ls, ok := agent.getSelector().(*liteSelector)
1573+
require.True(t, ok)
1574+
1575+
sentBefore := pair.RequestsSent()
1576+
msg := buildMsg(t, agent)
1577+
ls.HandleBindingRequest(msg, local, remote)
1578+
1579+
assert.Equal(t, sentBefore, pair.RequestsSent(),
1580+
"lite controlled agent must not send triggered checks when pair is connected")
1581+
})
1582+
1583+
t.Run("NominationStillAccepted", func(t *testing.T) {
1584+
// A lite agent must still accept USE-CANDIDATE and set the selected pair
1585+
// (RFC 8445 §7.3.2), even though it never sends its own checks.
1586+
agent, local, remote, pair := setupAgent(t)
1587+
pair.state = CandidatePairStateSucceeded
1588+
1589+
ls, ok := agent.getSelector().(*liteSelector)
1590+
require.True(t, ok)
1591+
1592+
assert.Nil(t, agent.getSelectedPair(), "no pair selected yet")
1593+
1594+
msg, err := stun.Build(stun.BindingRequest,
1595+
stun.TransactionID,
1596+
stun.NewUsername(agent.localUfrag+":"+agent.remoteUfrag),
1597+
UseCandidate(),
1598+
stun.NewShortTermIntegrity(agent.localPwd),
1599+
stun.Fingerprint,
1600+
)
1601+
require.NoError(t, err)
1602+
1603+
ls.HandleBindingRequest(msg, local, remote)
1604+
1605+
assert.Equal(t, pair, agent.getSelectedPair(),
1606+
"lite controlled agent must accept nomination and set selected pair")
1607+
// Still no triggered check emitted
1608+
assert.Equal(t, uint64(0), pair.RequestsSent())
1609+
})
1610+
}
1611+
1612+
// TestLiteMode_FullToLite_Integration is an end-to-end test for the most common
1613+
// lite mode deployment: a full ICE agent (controlling) connects to a lite agent
1614+
// (controlled). The full agent performs connectivity checks and nominates; the
1615+
// lite agent responds to STUN but never generates checks of its own.
1616+
func TestLiteMode_FullToLite_Integration(t *testing.T) {
1617+
defer test.CheckRoutines(t)()
1618+
defer test.TimeOut(time.Second * 30).Stop()
1619+
1620+
oneHour := time.Hour
1621+
keepaliveInterval := time.Millisecond * 20
1622+
1623+
// Full agent — will become the controlling agent (Dial).
1624+
fullNotifier, fullConnected := onConnected()
1625+
fullAgent, err := NewAgent(&AgentConfig{
1626+
NetworkTypes: []NetworkType{NetworkTypeUDP4},
1627+
MulticastDNSMode: MulticastDNSModeDisabled,
1628+
KeepaliveInterval: &keepaliveInterval,
1629+
CheckInterval: &oneHour,
1630+
})
1631+
require.NoError(t, err)
1632+
require.NoError(t, fullAgent.OnConnectionStateChange(fullNotifier))
1633+
t.Cleanup(func() { require.NoError(t, fullAgent.Close()) })
1634+
1635+
// Lite agent — will become the controlled agent (Accept).
1636+
liteNotifier, liteConnected := onConnected()
1637+
liteAgent, err := NewAgent(&AgentConfig{
1638+
NetworkTypes: []NetworkType{NetworkTypeUDP4},
1639+
MulticastDNSMode: MulticastDNSModeDisabled,
1640+
KeepaliveInterval: &keepaliveInterval,
1641+
CheckInterval: &oneHour,
1642+
Lite: true,
1643+
CandidateTypes: []CandidateType{CandidateTypeHost},
1644+
})
1645+
require.NoError(t, err)
1646+
require.NoError(t, liteAgent.OnConnectionStateChange(liteNotifier))
1647+
t.Cleanup(func() { require.NoError(t, liteAgent.Close()) })
1648+
1649+
// fullAgent.Accept / liteAgent.Dial => fullAgent=controlled, liteAgent=controlling.
1650+
// We want fullAgent=controlling, so we use connect() which calls
1651+
// aAgent.Accept (=fullAgent controlled) and bAgent.Dial (=liteAgent controlling).
1652+
// Swap: pass liteAgent as aAgent (Accept=controlled) and fullAgent as bAgent (Dial=controlling).
1653+
liteConn, fullConn := connect(t, liteAgent, fullAgent)
1654+
1655+
<-fullConnected
1656+
<-liteConnected
1657+
1658+
// Verify the lite agent never sent its own connectivity checks.
1659+
err = liteAgent.loop.Run(liteAgent.loop, func(_ context.Context) {
1660+
for _, pair := range liteAgent.checklist {
1661+
assert.Equal(t, uint64(0), pair.RequestsSent(),
1662+
"lite agent must not send any connectivity checks")
1663+
}
1664+
})
1665+
require.NoError(t, err)
1666+
1667+
// Both agents should be able to exchange data.
1668+
require.True(t, sendUntilDone(t, fullConn, liteConn, 100))
1669+
require.True(t, sendUntilDone(t, liteConn, fullConn, 100))
1670+
1671+
closePipe(t, liteConn, fullConn)
1672+
}

0 commit comments

Comments
 (0)