Fixed TCP bugs, added TURN transport type option (#908)

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.
This commit is contained in:
sirzooro
2026-04-14 05:43:24 +02:00
committed by GitHub
parent 23e8708890
commit ffbe6d5f0b
8 changed files with 1209 additions and 42 deletions
+18 -14
View File
@@ -119,9 +119,10 @@ type Agent struct {
selectedPair atomic.Value // *CandidatePair
urls []*stun.URI
networkTypes []NetworkType
addressRewriteRules []AddressRewriteRule
urls []*stun.URI
networkTypes []NetworkType
turnTransportProtocols []NetworkType
addressRewriteRules []AddressRewriteRule
buf *packetio.Buffer
@@ -324,6 +325,16 @@ func createAgentBase(config *AgentConfig) (*Agent, error) {
return nil, ErrPort
}
normalizedNetworkTypes, err := sanitizeTransportNetworkTypes(config.NetworkTypes)
if err != nil {
return nil, err
}
normalizedTURNTransportProtocols, err := sanitizeTransportNetworkTypes(config.turnTransportProtocols)
if err != nil {
return nil, err
}
mDNSName, mDNSMode, err := setupMDNSConfig(config)
if err != nil {
return nil, err
@@ -346,7 +357,8 @@ func createAgentBase(config *AgentConfig) (*Agent, error) {
remoteCandidates: make(map[NetworkType][]Candidate),
pairsByID: make(map[uint64]*CandidatePair),
urls: config.Urls,
networkTypes: config.NetworkTypes,
networkTypes: normalizedNetworkTypes,
turnTransportProtocols: normalizedTURNTransportProtocols,
onConnected: make(chan struct{}),
buf: packetio.NewBuffer(),
startedCh: startedCtx.Done(),
@@ -1152,12 +1164,6 @@ func (a *Agent) addRemoteCandidate(cand Candidate) bool { //nolint:cyclop
return false
}
if len(a.networkTypes) > 0 && !slices.Contains(a.networkTypes, cand.NetworkType()) {
a.log.Infof("Ignoring remote candidate with disabled network type %s: %s", cand.NetworkType(), cand)
return false
}
set := a.remoteCandidates[cand.NetworkType()]
for _, candidate := range set {
@@ -1174,10 +1180,8 @@ func (a *Agent) addRemoteCandidate(cand Candidate) bool { //nolint:cyclop
acceptRemotePassiveTCPCandidate := false
// Assert that TCP4 or TCP6 is a enabled NetworkType locally
if !a.disableActiveTCP && cand.TCPType() == TCPTypePassive {
for _, networkType := range a.networkTypes {
if cand.NetworkType() == networkType {
acceptRemotePassiveTCPCandidate = true
}
if slices.Contains(configuredNetworkTypes(a.networkTypes), cand.NetworkType()) {
acceptRemotePassiveTCPCandidate = true
}
}
+16 -2
View File
@@ -112,10 +112,24 @@ type AgentConfig struct {
// connecting state.
CheckInterval *time.Duration
// NetworkTypes is an optional configuration for disabling or enabling
// support for specific network types.
// NetworkTypes controls the candidate network types exposed in ICE candidates
// and used for pairing.
//
// This is independent from the TURN client-to-server transport configured via
// WithTURNTransportProtocols. Supported values are the NetworkType variants
// (NetworkTypeUDP4, NetworkTypeUDP6, NetworkTypeTCP4, NetworkTypeTCP6).
// When empty, all candidate network types are enabled by default.
NetworkTypes []NetworkType
// turnTransportProtocols restricts protocols used internally by this agent when connecting
// to TURN servers (the TURN client <-> TURN server transport).
//
// This is independent from NetworkTypes, which controls candidate network types
// exposed in ICE and used for pairing. Configure this via
// WithTURNTransportProtocols. Supported values are the NetworkType variants
// (NetworkTypeUDP4, NetworkTypeUDP6, NetworkTypeTCP4, NetworkTypeTCP6).
turnTransportProtocols []NetworkType
// CandidateTypes is an optional configuration for disabling or enabling
// support for specific candidate types.
CandidateTypes []CandidateType
+55 -2
View File
@@ -856,7 +856,9 @@ func WithNetworkMonitorInterval(interval time.Duration) AgentOption {
}
}
// WithNetworkTypes sets the enabled network types for candidate gathering.
// WithNetworkTypes sets the enabled candidate network types for candidate gathering.
// This controls the network types exposed in ICE candidates and used for pairing.
// Use WithTURNTransportProtocols to control the local TURN client-to-server transport.
// By default, all network types are enabled.
//
// Example:
@@ -870,12 +872,63 @@ func WithNetworkTypes(networkTypes []NetworkType) AgentOption {
return ErrAgentOptionNotUpdatable
}
a.networkTypes = networkTypes
normalized, err := sanitizeTransportNetworkTypes(networkTypes)
if err != nil {
return err
}
a.networkTypes = normalized
return nil
}
}
// WithTURNTransportProtocols restricts protocols used by this agent when
// connecting to TURN servers (TURN client <-> TURN server transport).
//
// This is independent from WithNetworkTypes, which controls ICE candidate
// network types announced to the peer. Supported values are
// NetworkTypeUDP4/UDP6 and NetworkTypeTCP4/TCP6.
func WithTURNTransportProtocols(protocols []NetworkType) AgentOption {
return func(a *Agent) error {
if a.constructed {
return ErrAgentOptionNotUpdatable
}
normalized, err := sanitizeTransportNetworkTypes(protocols)
if err != nil {
return err
}
a.turnTransportProtocols = normalized
return nil
}
}
func sanitizeTransportNetworkTypes(types []NetworkType) ([]NetworkType, error) {
if len(types) == 0 {
return nil, nil
}
seen := map[NetworkType]struct{}{}
out := make([]NetworkType, 0, len(types))
for _, networkType := range types {
if !networkType.IsUDP() && !networkType.IsTCP() {
return nil, ErrProtoType
}
if _, ok := seen[networkType]; ok {
continue
}
seen[networkType] = struct{}{}
out = append(out, networkType)
}
return out, nil
}
// WithCandidateTypes sets the enabled candidate types for gathering.
// By default, host, server reflexive, and relay candidates are enabled.
//
+80
View File
@@ -616,6 +616,86 @@ func TestWithNetworkTypesAppliedBeforeRestart(t *testing.T) {
})
}
func TestWithNetworkTypes(t *testing.T) {
t.Run("applies option", func(t *testing.T) {
agent, err := NewAgentWithOptions(
WithNetworkTypes([]NetworkType{NetworkTypeUDP4, NetworkTypeTCP4}),
)
require.NoError(t, err)
defer func() {
require.NoError(t, agent.Close())
}()
require.Equal(t, []NetworkType{NetworkTypeUDP4, NetworkTypeTCP4}, agent.networkTypes)
})
t.Run("deduplicates values", func(t *testing.T) {
agent, err := NewAgentWithOptions(
WithNetworkTypes([]NetworkType{NetworkTypeUDP4, NetworkTypeUDP4, NetworkTypeTCP4}),
)
require.NoError(t, err)
defer func() {
require.NoError(t, agent.Close())
}()
require.Equal(t, []NetworkType{NetworkTypeUDP4, NetworkTypeTCP4}, agent.networkTypes)
})
t.Run("rejects unsupported value", func(t *testing.T) {
_, err := NewAgentWithOptions(
WithNetworkTypes([]NetworkType{NetworkType(0)}),
)
require.ErrorIs(t, err, ErrProtoType)
})
t.Run("rejects unsupported value from config", func(t *testing.T) {
_, err := NewAgent(&AgentConfig{
NetworkTypes: []NetworkType{NetworkType(0)},
})
require.ErrorIs(t, err, ErrProtoType)
})
}
func TestWithTURNTransportProtocols(t *testing.T) {
t.Run("applies option", func(t *testing.T) {
agent, err := NewAgentWithOptions(
WithTURNTransportProtocols([]NetworkType{NetworkTypeTCP4}),
)
require.NoError(t, err)
defer func() {
require.NoError(t, agent.Close())
}()
require.Equal(t, []NetworkType{NetworkTypeTCP4}, agent.turnTransportProtocols)
})
t.Run("deduplicates protocols", func(t *testing.T) {
agent, err := NewAgentWithOptions(
WithTURNTransportProtocols([]NetworkType{NetworkTypeTCP4, NetworkTypeTCP4, NetworkTypeUDP4}),
)
require.NoError(t, err)
defer func() {
require.NoError(t, agent.Close())
}()
require.Equal(t, []NetworkType{NetworkTypeTCP4, NetworkTypeUDP4}, agent.turnTransportProtocols)
})
t.Run("rejects unsupported proto", func(t *testing.T) {
_, err := NewAgentWithOptions(
WithTURNTransportProtocols([]NetworkType{NetworkType(0)}),
)
require.ErrorIs(t, err, ErrProtoType)
})
t.Run("rejects unsupported proto from config", func(t *testing.T) {
_, err := NewAgent(&AgentConfig{
turnTransportProtocols: []NetworkType{NetworkType(0)},
})
require.ErrorIs(t, err, ErrProtoType)
})
}
func TestWithCandidateTypesAffectsURLValidation(t *testing.T) {
stunURL, err := stun.ParseURI("stun:example.com:3478")
require.NoError(t, err)
+149 -4
View File
@@ -443,7 +443,7 @@ func TestHandlePeerReflexive(t *testing.T) { //nolint:cyclop,maintidx
}))
})
t.Run("prflx candidate is discarded when network type is disabled", func(t *testing.T) {
t.Run("prflx candidate is stored even when network type is disabled", func(t *testing.T) {
agent, err := NewAgentWithOptions(
WithNetworkTypes([]NetworkType{NetworkTypeTCP4}),
)
@@ -479,7 +479,8 @@ func TestHandlePeerReflexive(t *testing.T) { //nolint:cyclop,maintidx
// nolint: contextcheck
agent.handleInbound(msg, local, remote)
require.Len(t, agent.remoteCandidates, 0)
require.Len(t, agent.remoteCandidates, 1)
require.Len(t, agent.remoteCandidates[NetworkTypeUDP4], 1)
}))
})
@@ -522,7 +523,7 @@ func TestHandlePeerReflexive(t *testing.T) { //nolint:cyclop,maintidx
})
}
func TestAddRemoteCandidateRespectsNetworkTypes(t *testing.T) {
func TestAddRemoteCandidateStoresCandidatesIndependentlyOfNetworkTypes(t *testing.T) {
defer test.CheckRoutines(t)()
agent, err := NewAgentWithOptions(
@@ -547,7 +548,21 @@ func TestAddRemoteCandidateRespectsNetworkTypes(t *testing.T) {
return false
}
return len(actual) == 1 && actual[0].Address() == udpCandidate.Address()
if len(actual) != 2 {
return false
}
var hasUDP, hasTCP bool
for _, c := range actual {
if c.Address() == udpCandidate.Address() {
hasUDP = true
}
if c.Address() == tcpCandidate.Address() {
hasTCP = true
}
}
return hasUDP && hasTCP
}, time.Second, 10*time.Millisecond)
}
@@ -3104,6 +3119,7 @@ func TestAgentUpdateOptions(t *testing.T) {
"WithContinualGatheringPolicy": WithContinualGatheringPolicy(GatherOnce),
"WithNetworkMonitorInterval": WithNetworkMonitorInterval(time.Second),
"WithNetworkTypes": WithNetworkTypes([]NetworkType{NetworkTypeUDP4}),
"WithTURNTransportProtocols": WithTURNTransportProtocols([]NetworkType{NetworkTypeTCP4}),
"WithCandidateTypes": WithCandidateTypes([]CandidateType{CandidateTypeHost}),
"WithAutomaticRenomination": WithAutomaticRenomination(time.Second),
"WithInterfaceFilter": WithInterfaceFilter(func(string) bool { return true }),
@@ -3163,6 +3179,135 @@ func TestMDNSQueryTimeout(t *testing.T) {
})
}
func TestAddRemoteCandidateIndependentFromTURNTransportSelection(t *testing.T) {
t.Run("accepts UDP relay candidate with tcp-only configured network types and TURN/TCP URL", func(t *testing.T) {
agent, err := NewAgent(&AgentConfig{
NetworkTypes: []NetworkType{NetworkTypeTCP4},
CandidateTypes: []CandidateType{CandidateTypeRelay},
Urls: []*stun.URI{{
Scheme: stun.SchemeTypeTURN,
Proto: stun.ProtoTypeTCP,
Host: "turn.example.com",
Port: 3478,
Username: "user",
Password: "pass",
}},
})
require.NoError(t, err)
defer func() {
require.NoError(t, agent.Close())
}()
cand, err := NewCandidateRelay(&CandidateRelayConfig{
Network: udp,
Address: "198.51.100.2",
Port: 5000,
Component: ComponentRTP,
RelAddr: "192.0.2.10",
RelPort: 4000,
})
require.NoError(t, err)
require.NoError(t, agent.loop.Run(agent.loop, func(_ context.Context) {
accepted := agent.addRemoteCandidate(cand) // nolint:contextcheck
require.True(t, accepted)
require.Len(t, agent.remoteCandidates[NetworkTypeUDP4], 1)
}))
})
// nolint:dupl
t.Run("accepts UDP host candidate with tcp-only configured network types and TURN/TCP URL", func(t *testing.T) {
agent, err := NewAgent(&AgentConfig{
NetworkTypes: []NetworkType{NetworkTypeTCP4},
CandidateTypes: []CandidateType{CandidateTypeRelay},
Urls: []*stun.URI{{
Scheme: stun.SchemeTypeTURN,
Proto: stun.ProtoTypeTCP,
Host: "turn.example.com",
Port: 3478,
Username: "user",
Password: "pass",
}},
})
require.NoError(t, err)
defer func() {
require.NoError(t, agent.Close())
}()
cand, err := UnmarshalCandidate("1052353102 1 udp 1675624447 198.51.100.20 5002 typ host")
require.NoError(t, err)
require.NoError(t, agent.loop.Run(agent.loop, func(_ context.Context) {
accepted := agent.addRemoteCandidate(cand) // nolint:contextcheck
require.True(t, accepted)
require.Len(t, agent.remoteCandidates[NetworkTypeUDP4], 1)
}))
})
// nolint:dupl
t.Run("accepts UDP srflx candidate with tcp-only configured network types and TURN/TCP URL", func(t *testing.T) {
agent, err := NewAgent(&AgentConfig{
NetworkTypes: []NetworkType{NetworkTypeTCP4},
CandidateTypes: []CandidateType{CandidateTypeRelay},
Urls: []*stun.URI{{
Scheme: stun.SchemeTypeTURN,
Proto: stun.ProtoTypeTCP,
Host: "turn.example.com",
Port: 3478,
Username: "user",
Password: "pass",
}},
})
require.NoError(t, err)
defer func() {
require.NoError(t, agent.Close())
}()
cand, err := UnmarshalCandidate(
"1052353102 1 udp 1675624447 198.51.100.21 5003 typ srflx raddr 192.0.2.21 rport 4003")
require.NoError(t, err)
require.NoError(t, agent.loop.Run(agent.loop, func(_ context.Context) {
accepted := agent.addRemoteCandidate(cand) // nolint:contextcheck
require.True(t, accepted)
require.Len(t, agent.remoteCandidates[NetworkTypeUDP4], 1)
}))
})
t.Run("stores UDP relay candidate regardless of TURN URL transport", func(t *testing.T) {
agent, err := NewAgent(&AgentConfig{
NetworkTypes: []NetworkType{NetworkTypeTCP4},
CandidateTypes: []CandidateType{CandidateTypeRelay},
Urls: []*stun.URI{{
Scheme: stun.SchemeTypeTURN,
Proto: stun.ProtoTypeUDP,
Host: "turn.example.com",
Port: 3478,
}},
})
require.NoError(t, err)
defer func() {
require.NoError(t, agent.Close())
}()
cand, err := NewCandidateRelay(&CandidateRelayConfig{
Network: udp,
Address: "198.51.100.3",
Port: 5001,
Component: ComponentRTP,
RelAddr: "192.0.2.11",
RelPort: 4001,
})
require.NoError(t, err)
require.NoError(t, agent.loop.Run(agent.loop, func(_ context.Context) {
accepted := agent.addRemoteCandidate(cand) // nolint:contextcheck
require.True(t, accepted)
require.Len(t, agent.remoteCandidates[NetworkTypeUDP4], 1)
}))
})
}
type localAddrTCPMux struct {
addr net.Addr
}
+36 -19
View File
@@ -64,30 +64,38 @@ func urlSupportsSrflxGathering(url stun.URI) bool {
return url.Scheme == stun.SchemeTypeSTUN || url.Scheme == stun.SchemeTypeTURN
}
func relayNetworkTypesForURL(url stun.URI, networkTypes []NetworkType) []NetworkType {
func relayNetworkTypesForConfiguredCandidates(networkTypes []NetworkType) []NetworkType {
// Relay allocations currently produce UDP relay endpoints, so relay candidate
// publication must be gated by configured UDP candidate network types.
res := []NetworkType{}
for _, networkType := range configuredNetworkTypes(networkTypes) {
if networkType.IsUDP() {
res = append(res, networkType)
}
}
return res
}
func turnNetworkTypesForURL(url stun.URI, networkTypes []NetworkType) []NetworkType {
proto := effectiveURLProtoType(url)
switch proto {
case stun.ProtoTypeUDP:
res := []NetworkType{}
for _, networkType := range configuredNetworkTypes(networkTypes) {
res := []NetworkType{}
for _, networkType := range configuredNetworkTypes(networkTypes) {
switch proto {
case stun.ProtoTypeUDP:
if networkType.IsUDP() {
res = append(res, networkType)
}
}
return res
case stun.ProtoTypeTCP:
res := []NetworkType{}
for _, networkType := range configuredNetworkTypes(networkTypes) {
case stun.ProtoTypeTCP:
if networkType.IsTCP() {
res = append(res, networkType)
}
default:
}
return res
default:
return nil
}
return res
}
// Close a net.Conn and log if we have a failure.
@@ -932,6 +940,13 @@ func (a *Agent) gatherCandidatesRelay(ctx context.Context, urls []*stun.URI) {
localAddrs := []ifaceAddr{}
if useFilteredLocalAddrs {
localAddrs = append(localAddrs, ifaces...)
if len(localAddrs) == 0 {
return
}
}
if len(relayNetworkTypesForConfiguredCandidates(a.networkTypes)) == 0 {
return
}
for _, url := range urls {
@@ -948,7 +963,9 @@ func (a *Agent) gatherCandidatesRelay(ctx context.Context, urls []*stun.URI) {
return
}
networkTypes := relayNetworkTypesForURL(*url, a.networkTypes)
urlProto := effectiveURLProtoType(*url)
networkTypes := turnNetworkTypesForURL(*url, a.turnTransportProtocols)
if len(networkTypes) == 0 {
continue
}
@@ -960,8 +977,6 @@ func (a *Agent) gatherCandidatesRelay(ctx context.Context, urls []*stun.URI) {
}
network := networkType.String()
urlProto := effectiveURLProtoType(*url)
bindAddrs := []string{}
if !useFilteredLocalAddrs { // nolint:nestif
if networkType.IsIPv6() {
@@ -1175,8 +1190,10 @@ func (a *Agent) gatherCandidatesRelay(ctx context.Context, urls []*stun.URI) {
return
}
// Relay allocations currently produce UDP relay endpoints regardless of
// whether the TURN control connection uses UDP/TCP/TLS/DTLS.
a.addRelayCandidates(ctx, relayEndpoint{
network: network,
network: udp,
address: rAddr.IP,
port: rAddr.Port,
relAddr: relAddr,
+83 -1
View File
@@ -1602,12 +1602,13 @@ func TestGatherCandidatesRelayRespectsNetworkTypeAndTransport(t *testing.T) {
}
})
t.Run("skips TCP transport URL when only UDP network types are enabled", func(t *testing.T) {
t.Run("skips TCP transport URL when TURN transport protocols allow only UDP", func(t *testing.T) {
stubClient := &stubTurnClient{}
agent, err := NewAgentWithOptions(
WithNet(newRelayGatherNet(&net.UDPAddr{IP: net.IPv4(10, 0, 0, 3), Port: 50000})),
WithNetworkTypes([]NetworkType{NetworkTypeUDP4}),
WithTURNTransportProtocols([]NetworkType{NetworkTypeUDP4}),
WithCandidateTypes([]CandidateType{CandidateTypeRelay}),
WithUrls([]*stun.URI{
{
@@ -1724,6 +1725,27 @@ func (m *mockConn) SetDeadline(time.Time) error { return io.EOF }
func (m *mockConn) SetReadDeadline(time.Time) error { return io.EOF }
func (m *mockConn) SetWriteDeadline(time.Time) error { return io.EOF }
type relayTCPProxyDialer struct {
localAddr *net.TCPAddr
}
func (d *relayTCPProxyDialer) Dial(string, string) (net.Conn, error) {
return &relayTCPProxyConn{localAddr: d.localAddr}, nil
}
type relayTCPProxyConn struct {
localAddr *net.TCPAddr
}
func (c *relayTCPProxyConn) Read([]byte) (int, error) { return 0, io.EOF }
func (c *relayTCPProxyConn) Write(p []byte) (int, error) { return len(p), nil }
func (c *relayTCPProxyConn) Close() error { return nil }
func (c *relayTCPProxyConn) LocalAddr() net.Addr { return c.localAddr }
func (c *relayTCPProxyConn) RemoteAddr() net.Addr { return &net.TCPAddr{} }
func (c *relayTCPProxyConn) SetDeadline(time.Time) error { return nil }
func (c *relayTCPProxyConn) SetReadDeadline(time.Time) error { return nil }
func (c *relayTCPProxyConn) SetWriteDeadline(time.Time) error { return nil }
func (m *mockProxy) Dial(string, string) (net.Conn, error) {
m.proxyWasDialed()
@@ -1778,6 +1800,66 @@ func TestTURNProxyDialer(t *testing.T) {
<-proxyWasDialed.Done()
}
func TestGatherCandidatesRelayTURNOverTCPProducesUDPRelayCandidate(t *testing.T) {
defer test.CheckRoutines(t)()
stubClient := &stubTurnClient{}
stubClient.relayConn = newStubPacketConn(&net.UDPAddr{IP: net.IPv4(203, 0, 113, 10), Port: 6000})
agent, err := NewAgentWithOptions(
WithNet(newRelayGatherNet(&net.UDPAddr{IP: net.IPv4(10, 0, 0, 5), Port: 50000})),
WithNetworkTypes([]NetworkType{NetworkTypeUDP4, NetworkTypeTCP4}),
WithCandidateTypes([]CandidateType{CandidateTypeRelay}),
WithUrls([]*stun.URI{
{
Scheme: stun.SchemeTypeTURN,
Host: "example.com",
Port: 3478,
Username: "username",
Password: "password",
Proto: stun.ProtoTypeTCP,
},
}),
WithMulticastDNSMode(MulticastDNSModeDisabled),
)
require.NoError(t, err)
defer func() {
require.NoError(t, agent.Close())
}()
agent.proxyDialer = &relayTCPProxyDialer{localAddr: &net.TCPAddr{IP: net.IPv4(10, 0, 0, 5), Port: 55000}}
agent.turnClientFactory = func(cfg *turn.ClientConfig) (turnClient, error) {
stubClient.cfgConn = cfg.Conn
return stubClient, nil
}
relayCandidateCh := make(chan *CandidateRelay, 1)
require.NoError(t, agent.OnCandidate(func(c Candidate) {
if c == nil || c.Type() != CandidateTypeRelay {
return
}
relay, ok := c.(*CandidateRelay)
require.True(t, ok)
select {
case relayCandidateCh <- relay:
default:
}
}))
agent.gatherCandidatesRelay(context.Background(), agent.urls)
select {
case relay := <-relayCandidateCh:
require.Equal(t, NetworkTypeUDP4, relay.NetworkType())
require.Equal(t, tcp, relay.RelayProtocol())
case <-time.After(time.Second):
require.FailNow(t, "expected relay candidate for TURN over TCP")
}
}
func buildSimpleVNet(t *testing.T) (*vnet.Router, *vnet.Net) {
t.Helper()
+772
View File
@@ -0,0 +1,772 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
package ice
import (
"errors"
"io"
"net"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/pion/stun/v3"
transport "github.com/pion/transport/v4"
"github.com/pion/transport/v4/test"
"github.com/pion/turn/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/net/proxy"
)
var errFirewallBlocked = errors.New("firewall blocked protocol")
type firewallNet struct {
allowUDP bool
allowTCP bool
mu sync.Mutex
listenUDPCalls int
listenPktCalls int
dialUDPCalls int
dialTCPCalls int
listenPktAddrs []string
listenPktNets []string
listenUDPNets []string
listenUDPAddrs []string
dialUDPNets []string
dialTCPNets []string
}
func newFirewallNet(allowUDP, allowTCP bool) *firewallNet {
return &firewallNet{allowUDP: allowUDP, allowTCP: allowTCP}
}
func (n *firewallNet) ListenPacket(network, address string) (net.PacketConn, error) {
n.mu.Lock()
n.listenPktCalls++
n.listenPktNets = append(n.listenPktNets, network)
n.listenPktAddrs = append(n.listenPktAddrs, address)
n.mu.Unlock()
if isUDPNetworkName(network) {
if !n.allowUDP {
return nil, errFirewallBlocked
}
udpAddr, err := net.ResolveUDPAddr(network, address)
if err != nil {
return nil, err
}
return newStubPacketConn(udpAddr), nil
}
if isTCPNetworkName(network) && !n.allowTCP {
return nil, errFirewallBlocked
}
return nil, transport.ErrNotSupported
}
func (n *firewallNet) ListenUDP(network string, laddr *net.UDPAddr) (transport.UDPConn, error) {
n.mu.Lock()
n.listenUDPCalls++
n.listenUDPNets = append(n.listenUDPNets, network)
if laddr != nil {
n.listenUDPAddrs = append(n.listenUDPAddrs, laddr.String())
} else {
n.listenUDPAddrs = append(n.listenUDPAddrs, "")
}
n.mu.Unlock()
if !n.allowUDP {
return nil, errFirewallBlocked
}
if laddr == nil {
laddr = &net.UDPAddr{IP: net.IPv4zero, Port: 0}
}
return net.ListenUDP(network, laddr) //nolint:wrapcheck
}
func (n *firewallNet) ListenTCP(string, *net.TCPAddr) (transport.TCPListener, error) {
return nil, transport.ErrNotSupported
}
func (n *firewallNet) Dial(string, string) (net.Conn, error) {
return nil, transport.ErrNotSupported
}
func (n *firewallNet) DialUDP(network string, laddr, raddr *net.UDPAddr) (transport.UDPConn, error) {
n.mu.Lock()
n.dialUDPCalls++
n.dialUDPNets = append(n.dialUDPNets, network)
n.mu.Unlock()
if !n.allowUDP {
return nil, errFirewallBlocked
}
return net.DialUDP(network, laddr, raddr) //nolint:wrapcheck
}
func (n *firewallNet) DialTCP(network string, laddr, raddr *net.TCPAddr) (transport.TCPConn, error) {
n.mu.Lock()
n.dialTCPCalls++
n.dialTCPNets = append(n.dialTCPNets, network)
n.mu.Unlock()
if !n.allowTCP {
return nil, errFirewallBlocked
}
return net.DialTCP(network, laddr, raddr) //nolint:wrapcheck
}
func (n *firewallNet) ResolveIPAddr(network, address string) (*net.IPAddr, error) {
return net.ResolveIPAddr(network, address)
}
func (n *firewallNet) ResolveUDPAddr(network, address string) (*net.UDPAddr, error) {
return net.ResolveUDPAddr(network, address)
}
func (n *firewallNet) ResolveTCPAddr(network, address string) (*net.TCPAddr, error) {
return net.ResolveTCPAddr(network, address)
}
func (n *firewallNet) Interfaces() ([]*transport.Interface, error) {
iface := transport.NewInterface(net.Interface{
Index: 1,
MTU: 1500,
Name: "fw0",
Flags: net.FlagUp | net.FlagLoopback,
})
iface.AddAddress(&net.IPNet{IP: net.IPv4(127, 0, 0, 1), Mask: net.CIDRMask(8, 32)})
return []*transport.Interface{iface}, nil
}
func (n *firewallNet) InterfaceByIndex(index int) (*transport.Interface, error) {
ifaces, err := n.Interfaces()
if err != nil {
return nil, err
}
for _, iface := range ifaces {
if iface.Index == index {
return iface, nil
}
}
return nil, transport.ErrInterfaceNotFound
}
func (n *firewallNet) InterfaceByName(name string) (*transport.Interface, error) {
ifaces, err := n.Interfaces()
if err != nil {
return nil, err
}
for _, iface := range ifaces {
if iface.Name == name {
return iface, nil
}
}
return nil, transport.ErrInterfaceNotFound
}
func (n *firewallNet) CreateDialer(*net.Dialer) transport.Dialer {
return nil
}
func (n *firewallNet) CreateListenConfig(*net.ListenConfig) transport.ListenConfig {
return nil
}
func (n *firewallNet) counts() (listenUDP, listenPacket, dialUDP, dialTCP int) {
n.mu.Lock()
defer n.mu.Unlock()
return n.listenUDPCalls, n.listenPktCalls, n.dialUDPCalls, n.dialTCPCalls
}
type firewallProxyDialer struct {
allowTCP bool
mu sync.Mutex
dialCalls int
}
func (d *firewallProxyDialer) Dial(network, _ string) (net.Conn, error) {
d.mu.Lock()
d.dialCalls++
d.mu.Unlock()
if !isTCPNetworkName(network) || !d.allowTCP {
return nil, errFirewallBlocked
}
return &matrixTCPConn{
local: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 50000},
remote: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 3478},
}, nil
}
func (d *firewallProxyDialer) count() int {
d.mu.Lock()
defer d.mu.Unlock()
return d.dialCalls
}
type matrixTCPConn struct {
local net.Addr
remote net.Addr
}
func (c *matrixTCPConn) Read([]byte) (int, error) { return 0, io.EOF }
func (c *matrixTCPConn) Write(p []byte) (int, error) { return len(p), nil }
func (c *matrixTCPConn) Close() error { return nil }
func (c *matrixTCPConn) LocalAddr() net.Addr { return c.local }
func (c *matrixTCPConn) RemoteAddr() net.Addr { return c.remote }
func (c *matrixTCPConn) SetDeadline(time.Time) error { return nil }
func (c *matrixTCPConn) SetReadDeadline(time.Time) error { return nil }
func (c *matrixTCPConn) SetWriteDeadline(time.Time) error { return nil }
func isUDPNetworkName(network string) bool {
return strings.HasPrefix(network, "udp")
}
func isTCPNetworkName(network string) bool {
return strings.HasPrefix(network, "tcp")
}
func gatherAndCollectCandidates(t *testing.T, agent *Agent) []Candidate {
t.Helper()
var (
mu sync.Mutex
candidates []Candidate
done = make(chan struct{})
)
require.NoError(t, agent.OnCandidate(func(c Candidate) {
if c == nil {
close(done)
return
}
mu.Lock()
candidates = append(candidates, c)
mu.Unlock()
}))
require.NoError(t, agent.GatherCandidates())
select {
case <-done:
case <-time.After(3 * time.Second):
assert.FailNow(t, "candidate gathering did not finish in time")
}
mu.Lock()
defer mu.Unlock()
out := make([]Candidate, len(candidates))
copy(out, candidates)
return out
}
func TestTransportFilteringRelayMatrix(t *testing.T) { // nolint:cyclop
defer test.CheckRoutines(t)()
type testCase struct {
name string
allowUDP bool
allowTCP bool
networkTypes []NetworkType
turnScheme stun.SchemeType
turnProto stun.ProtoType
turnAllowed []NetworkType
expectFactoryMinCalls int
expectRelayCandidate bool
}
testCases := []testCase{
{
name: "tcp-only firewall with udp relay config and TURN/TCP gathers relay",
allowUDP: false,
allowTCP: true,
networkTypes: []NetworkType{NetworkTypeUDP4},
turnScheme: stun.SchemeTypeTURN,
turnProto: stun.ProtoTypeTCP,
expectFactoryMinCalls: 1,
expectRelayCandidate: true,
},
{
name: "udp-only firewall with udp-only config and TURN/UDP gathers relay",
allowUDP: true,
allowTCP: false,
networkTypes: []NetworkType{NetworkTypeUDP4},
turnScheme: stun.SchemeTypeTURN,
turnProto: stun.ProtoTypeUDP,
expectFactoryMinCalls: 1,
expectRelayCandidate: true,
},
{
name: "tcp-only candidate config skips relay gathering",
allowUDP: false,
allowTCP: true,
networkTypes: []NetworkType{NetworkTypeTCP4},
turnScheme: stun.SchemeTypeTURN,
turnProto: stun.ProtoTypeUDP,
expectFactoryMinCalls: 0,
expectRelayCandidate: false,
},
{
name: "udp relay config with TURN/TCP fails when tcp blocked by firewall",
allowUDP: true,
allowTCP: false,
networkTypes: []NetworkType{NetworkTypeUDP4},
turnScheme: stun.SchemeTypeTURN,
turnProto: stun.ProtoTypeTCP,
expectFactoryMinCalls: 0,
expectRelayCandidate: false,
},
{
name: "tcp-only firewall with udp relay config and TURNS/TCP gathers relay",
allowUDP: false,
allowTCP: true,
networkTypes: []NetworkType{NetworkTypeUDP4},
turnScheme: stun.SchemeTypeTURNS,
turnProto: stun.ProtoTypeTCP,
expectFactoryMinCalls: 1,
expectRelayCandidate: true,
},
{
name: "tcp-only config with TURN URL without transport param defaults to UDP and is filtered",
allowUDP: false,
allowTCP: true,
networkTypes: []NetworkType{NetworkTypeTCP4},
turnScheme: stun.SchemeTypeTURN,
turnProto: stun.ProtoTypeUnknown,
expectFactoryMinCalls: 0,
expectRelayCandidate: false,
},
{
name: "udp-only config with TURN URL without transport param defaults to UDP and gathers relay",
allowUDP: true,
allowTCP: false,
networkTypes: []NetworkType{NetworkTypeUDP4},
turnScheme: stun.SchemeTypeTURN,
turnProto: stun.ProtoTypeUnknown,
expectFactoryMinCalls: 1,
expectRelayCandidate: true,
},
{
name: "udp relay config with TURNS URL without transport param defaults to TCP and gathers relay",
allowUDP: false,
allowTCP: true,
networkTypes: []NetworkType{NetworkTypeUDP4},
turnScheme: stun.SchemeTypeTURNS,
turnProto: stun.ProtoTypeUnknown,
expectFactoryMinCalls: 1,
expectRelayCandidate: true,
},
{
name: "udp-only config with TURNS URL without transport param defaults to TCP and is filtered",
allowUDP: true,
allowTCP: false,
networkTypes: []NetworkType{NetworkTypeUDP4},
turnScheme: stun.SchemeTypeTURNS,
turnProto: stun.ProtoTypeUnknown,
expectFactoryMinCalls: 0,
expectRelayCandidate: false,
},
{
name: "TURN/TCP URL blocked by TURN transport option allowing UDP only",
allowUDP: false,
allowTCP: true,
networkTypes: []NetworkType{NetworkTypeUDP4},
turnScheme: stun.SchemeTypeTURN,
turnProto: stun.ProtoTypeTCP,
turnAllowed: []NetworkType{NetworkTypeUDP4},
expectFactoryMinCalls: 0,
expectRelayCandidate: false,
},
{
name: "TURNS default TCP allowed by TURN transport option",
allowUDP: false,
allowTCP: true,
networkTypes: []NetworkType{NetworkTypeUDP4},
turnScheme: stun.SchemeTypeTURNS,
turnProto: stun.ProtoTypeUnknown,
turnAllowed: []NetworkType{NetworkTypeTCP4},
expectFactoryMinCalls: 1,
expectRelayCandidate: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
netFW := newFirewallNet(tc.allowUDP, tc.allowTCP)
proxyDialer := &firewallProxyDialer{allowTCP: tc.allowTCP}
url := &stun.URI{
Scheme: tc.turnScheme,
Proto: tc.turnProto,
Host: "turn.example.com",
Port: 3478,
Username: "user",
Password: "pass",
}
opts := []AgentOption{
WithNet(netFW),
WithProxyDialer(proxy.Dialer(proxyDialer)),
WithCandidateTypes([]CandidateType{CandidateTypeRelay}),
WithNetworkTypes(tc.networkTypes),
WithMulticastDNSMode(MulticastDNSModeDisabled),
WithIncludeLoopback(),
WithUrls([]*stun.URI{url}),
}
if len(tc.turnAllowed) > 0 {
opts = append(opts, WithTURNTransportProtocols(tc.turnAllowed))
}
agent, err := NewAgentWithOptions(
opts...,
)
require.NoError(t, err)
defer func() {
require.NoError(t, agent.Close())
}()
factoryCalls := 0
agent.turnClientFactory = func(*turn.ClientConfig) (turnClient, error) {
factoryCalls++
return &stubTurnClient{
relayConn: newStubPacketConn(&net.UDPAddr{IP: net.IPv4(203, 0, 113, 50), Port: 6000}),
}, nil
}
candidates := gatherAndCollectCandidates(t, agent)
relayCandidates := 0
for _, c := range candidates {
if c.Type() == CandidateTypeRelay {
relayCandidates++
require.True(t, c.NetworkType().IsUDP(), "relay endpoint must be UDP")
}
}
require.GreaterOrEqual(t, factoryCalls, tc.expectFactoryMinCalls)
if tc.expectRelayCandidate {
require.Greater(t, relayCandidates, 0)
} else {
require.Equal(t, 0, relayCandidates)
}
_, _, _, dialTCP := netFW.counts()
if effectiveURLProtoType(*url) == stun.ProtoTypeTCP { // nolint:nestif
tcpAllowedByOption := len(tc.turnAllowed) == 0
if !tcpAllowedByOption {
for _, nt := range tc.turnAllowed {
if nt.IsTCP() {
tcpAllowedByOption = true
break
}
}
}
if tcpAllowedByOption {
require.Greater(t, proxyDialer.count()+dialTCP, 0, "expected TURN/TCP dial attempt")
} else {
require.Equal(t, 0, proxyDialer.count()+dialTCP, "unexpected TURN/TCP dial attempt")
}
}
})
}
}
func TestTransportFilteringSrflxMatrix(t *testing.T) {
defer test.CheckRoutines(t)()
serverPort := randomPort(t)
serverListener, err := net.ListenPacket("udp4", localhostIPStr+":"+strconv.Itoa(serverPort)) // nolint: noctx
require.NoError(t, err)
defer func() {
_ = serverListener.Close()
}()
server, err := turn.NewServer(turn.ServerConfig{
Realm: "pion.ly",
AuthHandler: optimisticAuthHandler,
PacketConnConfigs: []turn.PacketConnConfig{{
PacketConn: serverListener,
RelayAddressGenerator: &turn.RelayAddressGeneratorNone{Address: localhostIPStr},
}},
})
require.NoError(t, err)
defer func() {
require.NoError(t, server.Close())
}()
type testCase struct {
name string
allowUDP bool
networkTypes []NetworkType
turnScheme stun.SchemeType
turnProto stun.ProtoType
expectSrflx bool
}
testCases := []testCase{
{
name: "udp allowed and udp config with TURN/UDP gathers srflx",
allowUDP: true,
networkTypes: []NetworkType{NetworkTypeUDP4},
turnScheme: stun.SchemeTypeTURN,
turnProto: stun.ProtoTypeUDP,
expectSrflx: true,
},
{
name: "tcp-only config with TURN/TCP does not gather srflx",
allowUDP: false,
networkTypes: []NetworkType{NetworkTypeTCP4},
turnScheme: stun.SchemeTypeTURN,
turnProto: stun.ProtoTypeTCP,
expectSrflx: false,
},
{
name: "udp config with TURN/TCP transport is filtered for srflx",
allowUDP: true,
networkTypes: []NetworkType{NetworkTypeUDP4},
turnScheme: stun.SchemeTypeTURN,
turnProto: stun.ProtoTypeTCP,
expectSrflx: false,
},
{
name: "udp config with TURN URL without transport param defaults to UDP and gathers srflx",
allowUDP: true,
networkTypes: []NetworkType{NetworkTypeUDP4},
turnScheme: stun.SchemeTypeTURN,
turnProto: stun.ProtoTypeUnknown,
expectSrflx: true,
},
{
name: "udp config with TURNS URL without transport param defaults to TCP and is filtered for srflx",
allowUDP: true,
networkTypes: []NetworkType{NetworkTypeUDP4},
turnScheme: stun.SchemeTypeTURNS,
turnProto: stun.ProtoTypeUnknown,
expectSrflx: false,
},
{
name: "udp config with explicit TURNS/TCP is filtered for srflx",
allowUDP: true,
networkTypes: []NetworkType{NetworkTypeUDP4},
turnScheme: stun.SchemeTypeTURNS,
turnProto: stun.ProtoTypeTCP,
expectSrflx: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
netFW := newFirewallNet(tc.allowUDP, true)
url := &stun.URI{
Scheme: tc.turnScheme,
Proto: tc.turnProto,
Host: localhostIPStr,
Port: serverPort,
Username: "user",
Password: "pass",
}
agent, err := NewAgentWithOptions(
WithNet(netFW),
WithCandidateTypes([]CandidateType{CandidateTypeServerReflexive}),
WithNetworkTypes(tc.networkTypes),
WithMulticastDNSMode(MulticastDNSModeDisabled),
WithIncludeLoopback(),
WithSTUNGatherTimeout(200*time.Millisecond),
WithUrls([]*stun.URI{url}),
)
require.NoError(t, err)
defer func() {
require.NoError(t, agent.Close())
}()
candidates := gatherAndCollectCandidates(t, agent)
srflxCandidates := 0
for _, c := range candidates {
if c.Type() == CandidateTypeServerReflexive {
srflxCandidates++
}
}
if tc.expectSrflx {
require.Greater(t, srflxCandidates, 0)
} else {
require.Equal(t, 0, srflxCandidates)
}
})
}
}
func TestTransportFilteringHostMatrix(t *testing.T) {
defer test.CheckRoutines(t)()
type testCase struct {
name string
networkTypes []NetworkType
allowUDP bool
useTCPMux bool
expectHost bool
expectTCP bool
}
testCases := []testCase{
{
name: "udp config gathers udp host candidate",
networkTypes: []NetworkType{NetworkTypeUDP4},
allowUDP: true,
useTCPMux: false,
expectHost: true,
expectTCP: false,
},
{
name: "tcp config gathers tcp host candidate with tcp mux",
networkTypes: []NetworkType{NetworkTypeTCP4},
allowUDP: false,
useTCPMux: true,
expectHost: true,
expectTCP: true,
},
{
name: "tcp config without tcp mux yields no host candidate",
networkTypes: []NetworkType{NetworkTypeTCP4},
allowUDP: false,
useTCPMux: false,
expectHost: false,
expectTCP: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
netFW := newFirewallNet(tc.allowUDP, true)
opts := []AgentOption{
WithNet(netFW),
WithCandidateTypes([]CandidateType{CandidateTypeHost}),
WithNetworkTypes(tc.networkTypes),
WithMulticastDNSMode(MulticastDNSModeDisabled),
WithIncludeLoopback(),
}
if tc.useTCPMux {
opts = append(opts, WithTCPMux(&boundTCPMux{
localAddr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 34567},
}))
}
agent, err := NewAgentWithOptions(opts...)
require.NoError(t, err)
defer func() {
require.NoError(t, agent.Close())
}()
candidates := gatherAndCollectCandidates(t, agent)
hostCandidates := 0
tcpHosts := 0
for _, c := range candidates {
if c.Type() != CandidateTypeHost {
continue
}
hostCandidates++
if c.NetworkType().IsTCP() {
tcpHosts++
}
}
if tc.expectHost {
require.Greater(t, hostCandidates, 0)
} else {
require.Equal(t, 0, hostCandidates)
}
if tc.expectTCP {
require.Greater(t, tcpHosts, 0)
}
})
}
}
func TestTransportFilteringRelayTCPOnlyFirewallUDPRelayConfigTURNTCP(t *testing.T) {
defer test.CheckRoutines(t)()
netFW := newFirewallNet(false, true)
proxyDialer := &firewallProxyDialer{allowTCP: true}
agent, err := NewAgentWithOptions(
WithNet(netFW),
WithProxyDialer(proxy.Dialer(proxyDialer)),
WithCandidateTypes([]CandidateType{CandidateTypeRelay}),
WithNetworkTypes([]NetworkType{NetworkTypeUDP4}),
WithMulticastDNSMode(MulticastDNSModeDisabled),
WithIncludeLoopback(),
WithUrls([]*stun.URI{{
Scheme: stun.SchemeTypeTURN,
Proto: stun.ProtoTypeTCP,
Host: "turn.example.com",
Port: 3478,
Username: "user",
Password: "pass",
}}),
)
require.NoError(t, err)
defer func() {
require.NoError(t, agent.Close())
}()
factoryCalls := 0
agent.turnClientFactory = func(*turn.ClientConfig) (turnClient, error) {
factoryCalls++
return &stubTurnClient{
relayConn: newStubPacketConn(&net.UDPAddr{IP: net.IPv4(203, 0, 113, 77), Port: 6100}),
}, nil
}
candidates := gatherAndCollectCandidates(t, agent)
require.GreaterOrEqual(t, proxyDialer.count(), 1)
require.GreaterOrEqual(t, factoryCalls, 1)
relayCandidates := 0
for _, c := range candidates {
if c.Type() != CandidateTypeRelay {
continue
}
relayCandidates++
require.True(t, c.NetworkType().IsUDP())
}
require.Greater(t, relayCandidates, 0)
}