This PR fixes two race conditions introduced by peer-reflexive candidate
replacement and addresses a connectivity regression where an agent could
transition from Connected to Disconnected/Failed shortly after
replacement.
## Problems
1. Data race in candidate pair access:
- `replaceRedundantPeerReflexiveCandidates` updated `pair.Remote` in
place.
- At the same time, hot-path reads (for example `CandidatePair.Write`)
could read `pair.Remote` without synchronization.
2. Data race in remote candidate cache map:
- `candidateBase.handleInboundPacket` (non-STUN path) read/wrote
`remoteCandidateCaches` from recv loop goroutines.
- Concurrent writes also happened from agent-loop code
(`replaceRemoteCandidateCacheValues`).
3. Connectivity regression after replacement:
- When replacing a selected pair's remote candidate (prflx -> signaled),
the new candidate could have zero `LastReceived`/`LastSent`.
- `validateSelectedPair` uses `selectedPair.Remote.LastReceived()`,
which could cause quick `Connected -> Disconnected -> Failed`
transitions.
## Root Cause
Replacement logic preserved pair priority but mutated shared objects in
place and did not preserve candidate activity timestamps across
candidate object replacement.
## Fixes
### 1) Replace candidate pair objects instead of mutating `pair.Remote`
- Added helper `replacePairRemote(pair, remote)` to clone pair state
into a new `CandidatePair`.
- Preserved pair fields and runtime stats:
- `id`, role, state, nomination flags, binding counters.
- RTT fields, packet/byte counters, request/response counters.
- timestamp `atomic.Value` fields.
- Replaced references in:
- `a.checklist[i]`
- `a.pairsByID[pair.id]`
- If old pair was selected, published replacement via
`a.setSelectedPair(replacement)`.
### 2) Serialize remote cache access on agent loop
- Updated non-STUN path in `candidateBase.handleInboundPacket`:
- Cache validation, remote candidate lookup, `seen(false)`, and cache
insert now run inside `agent.loop.Run(...)`.
- This removes concurrent map read/write between recv loop and
agent-loop updates.
### 3) Preserve candidate activity on replacement
- Added `copyCandidateActivity(dst, src)` to transfer:
- `LastReceived`
- `LastSent`
- Applied before replacing references so selected-pair liveness checks
remain stable.
Fix relay transport semantics and decouple TURN control transport from
relay candidate network type
## Summary
This change fixes relay transport handling in two critical areas:
1. Some relay candidates were exposed with `tcp` network in the
candidate line, even though relayed endpoints are UDP.
2. Relay connectivity could fail when pion/ice was configured with
TCP-only network types, because TURN control transport selection and
relay candidate transport were incorrectly coupled.
The patch separates these concerns:
- Relay candidate transport is treated as UDP (current TURN allocation
behavior).
- TURN client-to-server transport is selected independently from URL
scheme/proto and a dedicated TURN transport protocol configuration.
## Bugs Fixed
### 1) Incorrect relay candidate network type in candidate lines
Before this change, relay candidates created through TURN over TCP/TLS
paths could carry TCP network in candidate metadata.
Now relay candidates are always emitted as UDP network candidates,
matching actual relayed endpoint behavior.
### 2) Relay connection failure in TCP-only configurations
Before this change, relay gathering used candidate network type
filtering to drive TURN control connection transport, which could block
valid TURN/TCP or TURNS flows depending on configured candidate network
types.
Now TURN control transport selection is independent and based on:
- Effective URL transport (explicit `transport` or implicit defaults:
TURN->UDP, TURNS->TCP)
- Allowed TURN transport protocols configuration
This enables relay connectivity in scenarios where TURN control
transport is TCP while relay endpoints remain UDP.
## Key Implementation Changes
- Introduced dedicated TURN control transport filtering path for relay
gathering.
- Kept relay candidate publication gated by UDP candidate support (relay
allocations currently produce UDP endpoints).
- Ensured relay candidate creation uses UDP network type regardless of
TURN control channel transport.
- Removed early remote candidate rejection based only on local network
type filters, so candidates are stored and compatibility is handled
later at pairing/sending stages.
- Added sanitization/deduplication for network-type based options and
TURN transport protocol options.
## API/Config Notes
- `WithNetworkTypes(...)` continues to control candidate network types
exposed/used for pairing.
- `WithTURNTransportProtocols(...)` controls TURN client<->server
transport independently.
- Config comments were updated to clarify these responsibilities and
defaults.
## Test Coverage Added/Updated
- Added comprehensive relay/srflx/host transport matrix coverage in
`transport_filtering_matrix_test.go`.
- Added explicit TURN/TURNS and implicit transport-default test cases.
- Added regression test verifying TURN-over-TCP still produces UDP relay
candidates.
- Updated remote-candidate behavior tests to reflect store-first
semantics.
- Added/updated option sanitization tests for network type and TURN
transport protocol handling.
Implements RFC 8838 §11.4 (“Receiving Trickled Candidates”) behavior so
that when a remote peer-reflexive (prflx) candidate is discovered first,
a later signaled equivalent candidate (host/srflx/relay) replaces it
in-place while preserving checklist/pair behavior.
## Key Changes
- **prflx replacement on trickle (RFC 8838 §11.4)**
- Replace redundant remote prflx candidates when a signaled candidate
with the same transport address arrives.
- Upgrade existing checklist pairs in-place (remote candidate swap),
while preserving the pair priority.
- Update local candidate remote-candidate caches so subsequent lookups
use the signaled candidate.
- Avoid creating duplicate pairs when the replacement occurs.
- **prflx priority from inbound STUN (RFC 5245 §7.1.3.2.1)**
- When a prflx candidate is discovered via an inbound Binding Request,
set its priority from the STUN `PRIORITY` attribute.
~~- **Candidate pair priority correctness (RFC 5245)**~~
~~- Fix pair-priority computation to use the RFC 5245 formula with
$2^{32}$ (not $2^{32}-1$).~~
~~- Add an internal helper for the computation and make it overflow-safe
via saturation.~~
~~- Add a pair-level priority override so prflx→signaled replacement can
preserve the previously computed pair priority.~~
(Restored original code, this change was not needed)
- **Handler notifier deadlock/starvation fix**
- Split the notifier’s single `running` flag into independent running
state for connection-state, candidate, and selected-pair queues.
- Prevent callback starvation that could cause tests (and user code)
waiting on connection-state transitions to hang.
~~- **Test stability improvements (Active TCP)**~~
~~- Prefer non-link-local IPv6 addresses and skip IPv6 cases when only
link-local addresses exist.~~
~~- Use mDNS on non-Windows platforms but disable it on Windows to
reduce flakiness.~~
~~- Increase the per-subtest timeout to reduce intermittent CI/dev
timeouts.~~
(Moved to another PR)
#### Reference issue
Fixes#622
Once a candidate pair reaches Succeeded state with a selected pair,
controlledSelector.HandleBindingRequest no longer sends a triggered
check (PingCandidate). Previously, every inbound Binding Request
unconditionally called PingCandidate, creating a ping-pong loop
where each Response triggered a new Request at 1/RTT speed.
After connection is established, consent freshness is maintained
by checkKeepalive() on a timer, so triggered checks are not needed.
Added regression tests verifying:
- No triggered check for succeeded+selected pairs
- Triggered check still sent during ICE checking phase
sync.Pool boxes values into any (interface{}).
Storing []byte directly causes the 24-byte slice
header to be heap-allocated on every Put().
Storing *[]byte avoids this since the pointer is
already on the heap.
Same pattern used in Go stdlib (fmt.pp,
encoding/json).
This adds a nonblocking API for ICE that will allow the PeerConnection's
`startTransports` to start ICE, *not* wait for ICE to connect and start
DTLS so the DTLS handshake can be piggybacked into the STUN messages
earlier.
## Summary
This change makes transport/network-type handling consistent across
candidate gathering and remote candidate admission.
It fixes cases where the ICE agent could use STUN/TURN URLs with
mismatched transport or accept remote candidates on disabled network
types.
## What changed
### 1) Remote candidate filtering by configured network types
- Added a guard in `addRemoteCandidate` to ignore candidates whose
`NetworkType` is not enabled in `AgentConfig.NetworkTypes`.
- Added logging for ignored candidates.
### 2) URL transport interpretation and filtering
- Added helpers:
- `effectiveURLProtoType` (applies default transport when URI transport
is omitted)
- `urlSupportsSrflxGathering`
- `relayNetworkTypesForURL`
- `configuredNetworkTypes`
- Applied this logic to srflx gathering (`gatherCandidatesSrflx`,
`gatherCandidatesSrflxUDPMux`) so only valid UDP URLs are used.
### 3) Relay gathering honors network types and filtered local binds
- Refactored relay gathering to select per-URL/per-network-type behavior
instead of hardcoded network assumptions.
- Kept support for interface/IP filtered local binding addresses in
relay/srflx paths.
- Replaced manual host:port formatting in srflx paths with
`net.JoinHostPort(..., strconv.Itoa(...))` for safer address
construction.
### 4) IPv6 TURN status (important)
- IPv6 TURN code paths were updated and covered by tests, but full IPv6
TURN support is not complete yet.
- For now, IPv6 TURN relay gathering is intentionally disabled in
runtime path.
- Related IPv6 relay test scenario is present but currently skipped
(`t.Skip("IPv6 TURN is not supported yet")`).
### Reference issue
Fixes#367
## Summary
This PR adds support for filtering **remote** candidate IP addresses
before they are accepted by the agent.
A new `RemoteIPFilter` callback can now be configured via both
`AgentConfig` and `WithRemoteIPFilter(...)`. The filter is applied
consistently when:
- remote candidates are added through `AddRemoteCandidate` / internal
`addRemoteCandidate`
- peer-reflexive candidates are discovered from inbound STUN Binding
requests
If a remote candidate is rejected by policy (or its address cannot be
parsed), it is ignored and not added.
## Motivation
`IPFilter` controls local candidate gathering, but there was no
symmetric policy control for incoming remote candidates. This change
enables allowlist/blocklist style enforcement for remote candidate
addresses.
## What changed
- `agent_config.go`
- Added `RemoteIPFilter func(net.IP) (keep bool)` to `AgentConfig`.
- `agent_options.go`
- Added `WithRemoteIPFilter(filter func(net.IP) bool) AgentOption`.
- `agent.go`
- Added `remoteIPFilter` field to `Agent`.
- Wired `AgentConfig.RemoteIPFilter` into agent construction.
- Added `shouldAcceptRemoteCandidate(cand Candidate) bool`.
- Applied filtering before adding remote candidates in
`addRemoteCandidate`.
- Applied filtering to newly created peer-reflexive candidates in
inbound request handling.
- Added warning logs when candidate address parsing fails or candidate
is filtered out.
- Tests
- `agent_options_test.go`
- Added `TestWithRemoteIPFilterOption`.
- `agent_test.go`
- Added coverage for filtering in direct remote candidate addition.
- Added coverage for filtering in `AddRemoteCandidate` asynchronous
path.
- Added inbound Binding request case verifying blocked prflx candidates
are discarded.
- Included `WithRemoteIPFilter` in non-updatable options test matrix.
## Summary
- When a network interface disappears (e.g. Kubernetes pods with
`host_network: true`), `listenUDPInPortRange` would try every port in
the range before giving up, causing high CPU usage
- Added `isInterfaceLevelError()` to detect `EADDRNOTAVAIL` — the error
the kernel returns when binding to an IP that is no longer assigned to
any local interface
- The loop now bails immediately on `EADDRNOTAVAIL` instead of
exhausting the entire port range
- Cross-platform support: handles both POSIX `EADDRNOTAVAIL` and Windows
`WSAEADDRNOTAVAIL` (10049) via platform-specific files
Fixes#779
## Test plan
- [x] `TestListenUDPInPortRange_BailsOnEADDRNOTAVAIL` — mock test
verifying early bail on `EADDRNOTAVAIL` wrapped in real kernel error
chain (`OpError → SyscallError → Errno`)
- [x] `TestListenUDPInPortRange_ContinuesOnPortBusyError` — regression
guard: `EADDRINUSE` still tries all ports
- [x] `TestListenUDPInPortRange_RealEADDRNOTAVAIL` — integration test
using actual kernel `bind()` against TEST-NET-1 (`192.0.2.1`), skipped
on WASM
- [x] Existing `DefaultsPortMinTo1024` and `DefaultsPortMaxToFFFF` still
pass
- [x] Builds on all platforms: Linux, macOS, Windows, WASM
- [x] `golangci-lint` passes with no new issues
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
## Summary
This PR fixes a bug where server-reflexive (srflx) STUN requests and
TURN relay UDP sockets could be sent from interfaces that were filtered
out by `InterfaceFilter` / `IPFilter`.
Previously:
- srflx gathering used wildcard local binds (`0.0.0.0` / `::`)
- relay TURN/UDP gathering used wildcard local bind (`0.0.0.0:0`)
That allowed kernel routing to choose source interfaces outside
configured filters, which could expose public IPs from non-allowed
adapters.
With this change, when interface/IP filters are configured:
- srflx gathering binds STUN sockets to filtered local interface
addresses only
- relay (TURN/UDP) gathering binds local sockets to filtered local
interface addresses only
## Root cause
- Wildcard local binds do not constrain source interface selection.
- `localInterfaces(...)` filtering was applied to host gathering but not
consistently to srflx and relay local bind paths.
## What changed
- `gather.go`
- Refactored `gatherCandidatesSrflx` to support two modes:
- **Filtered mode** (`interfaceFilter` or `ipFilter` set):
- Resolve filtered local interfaces via `localInterfaces(...)`.
- Bind srflx sockets per matching local address/family.
- **Default mode** (no filters):
- Preserve existing wildcard-bind behavior.
- Updated `gatherCandidatesRelay` (TURN/UDP path) with the same two-mode
behavior:
- **Filtered mode** binds relay UDP sockets per filtered local IPv4
address.
- **Default mode** preserves legacy `0.0.0.0:0` bind behavior.
- `gather_test.go`
- Added `srflxListenCaptureNet` and
`TestGatherCandidatesSrflxRespectsInterfaceFilter`.
- Added `relayListenCaptureNet` and
`TestGatherCandidatesRelayRespectsInterfaceFilter`.
- Kept existing relay behavior checks via
`TestGatherCandidatesRelayCallsAddRelayCandidates`.
## Backward compatibility
- No behavior change for users who do not configure `InterfaceFilter` /
`IPFilter`.
- When filters are configured, srflx/relay local bind behavior now
consistently honors those filters.
## Reference issue
Fixes#727
This PR contains the following updates:
| Package | Type | Update | Change |
|---|---|---|---|
| golang | stage | minor | `1.25-bookworm` → `1.26-bookworm` |
---
### Configuration
📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).
🚦 **Automerge**: Enabled.
♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.
🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.
---
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box
---
This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/pion/ice).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi45Ny4wIiwidXBkYXRlZEluVmVyIjoiNDIuOTcuMCIsInRhcmdldEJyYW5jaCI6Im1hc3RlciIsImxhYmVscyI6W119-->
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
#### Description
This fixes the handling of relay preferences. Previously,
`LocalPreference` for `CandidateRelay` was not being called, causing all
relay protocols to have the same priority. This change introduces
protocol-based preferences (UDP: 3, DTLS: 2, TCP: 1, TLS: 0).
#### Reference issue
Fixes#879