diff --git a/rtp/listen.go b/rtp/listen.go index 3c0084a..2893378 100644 --- a/rtp/listen.go +++ b/rtp/listen.go @@ -80,3 +80,55 @@ func ListenUDPPortRangeWithConfig(portMin, portMax int, ip netip.Addr, lc *net.L return conn.(*net.UDPConn), nil }) } + +// bindEvenRange binds an even UDP port within the given range. +func bindEvenRange(portMin, portMax int, ip netip.Addr, create bindFunc) (*net.UDPConn, error) { + if portMin <= 0 { + portMin = 1 + } + if portMax <= 0 || portMax > 0xFFFF { + portMax = 0xFFFF + } + evenMin := (portMin + 1) &^ 1 + evenMax := portMax &^ 1 + if evenMin > evenMax { + return nil, ErrListenFailed + } + ports := (evenMax-evenMin)/2 + 1 + portCurrent := evenMin + 2*rand.Intn(ports) + + for try := 0; try < ports; try++ { + c, err := create(portCurrent, ip) + if err == nil { + return c, nil + } else if !errors.Is(err, syscall.EADDRINUSE) { + return c, err + } + portCurrent += 2 + if portCurrent > evenMax { + portCurrent = evenMin + } + } + return nil, ErrListenFailed +} + +// ListenUDPEvenPortRange binds an even UDP port for RTP, per RFC 3550 convention. +func ListenUDPEvenPortRange(portMin, portMax int, ip netip.Addr) (*net.UDPConn, error) { + return bindEvenRange(portMin, portMax, ip, func(port int, ip netip.Addr) (*net.UDPConn, error) { + addr := &net.UDPAddr{IP: ip.AsSlice(), Port: port} + return net.ListenUDP("udp", addr) + }) +} + +// ListenUDPEvenPortRangeWithConfig is ListenUDPEvenPortRange with a custom net.ListenConfig. +func ListenUDPEvenPortRangeWithConfig(portMin, portMax int, ip netip.Addr, lc *net.ListenConfig) (*net.UDPConn, error) { + ctx := context.Background() // ctx is only used to resolve domain names, which we don't use here. + ipStr := ip.String() + return bindEvenRange(portMin, portMax, ip, func(port int, ip netip.Addr) (*net.UDPConn, error) { + conn, err := lc.ListenPacket(ctx, "udp", fmt.Sprintf("%s:%d", ipStr, port)) + if err != nil { + return nil, err + } + return conn.(*net.UDPConn), nil + }) +} diff --git a/rtp/listen_test.go b/rtp/listen_test.go new file mode 100644 index 0000000..b01e86d --- /dev/null +++ b/rtp/listen_test.go @@ -0,0 +1,42 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package rtp + +import ( + "net" + "net/netip" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestListenUDPEvenPortRange(t *testing.T) { + ip := netip.AddrFrom4([4]byte{127, 0, 0, 1}) + + // block the first even port; allocation must skip to the next one + const portMin, portMax = 34200, 34203 + blocker, err := net.ListenUDP("udp", &net.UDPAddr{IP: ip.AsSlice(), Port: 34200}) + require.NoError(t, err) + defer blocker.Close() + + c, err := ListenUDPEvenPortRange(portMin, portMax, ip) + require.NoError(t, err) + defer c.Close() + require.Equal(t, 34202, c.LocalAddr().(*net.UDPAddr).Port) + + // even ports exhausted + _, err = ListenUDPEvenPortRange(portMin, portMax, ip) + require.ErrorIs(t, err, ErrListenFailed) +}