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
14 changes: 14 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ type Config struct {
// if different from signaling IP
MediaUseExternalIP bool `yaml:"media_use_external_ip"`
MediaNAT1To1IP string `yaml:"media_nat_1_to_1_ip"`
// MediaListenIP binds RTP sockets to a specific local interface. Empty falls back to
// ListenIP when that is a specific address; otherwise RTP listens on 0.0.0.0.
// Distinct from media_nat_1_to_1_ip, which only affects the announced address in SDP.
MediaListenIP string `yaml:"media_listen_ip"`

MediaTimeout time.Duration `yaml:"media_timeout"`
MediaTimeoutInitial time.Duration `yaml:"media_timeout_initial"`
Expand Down Expand Up @@ -221,6 +225,16 @@ func (c *Config) Init() error {
return fmt.Errorf("media_use_external_ip and media_nat_1_to_1_ip can not both be set")
}

if c.MediaListenIP != "" {
ip, err := netip.ParseAddr(c.MediaListenIP)
if err != nil {
return fmt.Errorf("invalid media_listen_ip %q: %w", c.MediaListenIP, err)
}
if ip.IsUnspecified() {
return fmt.Errorf("media_listen_ip must be a specific local address, got %q", c.MediaListenIP)
}
}

return nil
}

Expand Down
49 changes: 49 additions & 0 deletions pkg/config/config_media_listen_ip_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// 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 config

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestConfigInitMediaListenIP(t *testing.T) {
t.Parallel()

t.Run("empty ok", func(t *testing.T) {
c := &Config{}
require.NoError(t, c.Init())
})

t.Run("specific address ok", func(t *testing.T) {
c := &Config{MediaListenIP: "127.0.0.1"}
require.NoError(t, c.Init())
})

t.Run("invalid address", func(t *testing.T) {
c := &Config{MediaListenIP: "10.0.0.5x"}
err := c.Init()
require.Error(t, err)
require.Contains(t, err.Error(), "media_listen_ip")
})

t.Run("unspecified rejected", func(t *testing.T) {
c := &Config{MediaListenIP: "0.0.0.0"}
err := c.Init()
require.Error(t, err)
require.Contains(t, err.Error(), "media_listen_ip")
})
}
33 changes: 33 additions & 0 deletions pkg/sip/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"fmt"
"net"
"net/netip"
"strings"
"time"

"github.com/livekit/mediatransportutil/pkg/rtcconfig"
Expand Down Expand Up @@ -132,3 +133,35 @@ func getLocalIP(localNet string) (netip.Addr, error) {

return netip.Addr{}, fmt.Errorf("no local interface found")
}

// resolveMediaBindIP chooses an explicit local address for RTP sockets.
//
// Binding is opt-in: only media_listen_ip or a specific listen_ip pin the socket.
// Otherwise RTP keeps listening on 0.0.0.0 so multi-interface deployments are unchanged.
// Do not infer from SignalingIPLocal — with nat_1_to_1_ip that value may be a public
// announce address not assigned locally, and even when local it may be an arbitrary
// first NIC from getLocalIP (e.g. docker0).
func resolveMediaBindIP(conf *config.Config) netip.Addr {
if conf == nil {
return netip.Addr{}
}
if ip, ok := parseSpecificListenIP(conf.MediaListenIP); ok {
return ip
}
if ip, ok := parseSpecificListenIP(conf.ListenIP); ok {
return ip
}
return netip.Addr{}
}

func parseSpecificListenIP(s string) (netip.Addr, bool) {
s = strings.TrimSpace(s)
if s == "" {
return netip.Addr{}, false
}
ip, err := netip.ParseAddr(s)
if err != nil || !ip.IsValid() || ip.IsUnspecified() {
return netip.Addr{}, false
}
return ip, true
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}
79 changes: 79 additions & 0 deletions pkg/sip/config_media_bind_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// 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 sip

import (
"net/netip"
"testing"

"github.com/stretchr/testify/require"

"github.com/livekit/sip/pkg/config"
)

func TestParseSpecificListenIP(t *testing.T) {
t.Parallel()

ip, ok := parseSpecificListenIP("127.0.0.1")
require.True(t, ok)
require.Equal(t, netip.MustParseAddr("127.0.0.1"), ip)

_, ok = parseSpecificListenIP("")
require.False(t, ok)
_, ok = parseSpecificListenIP("0.0.0.0")
require.False(t, ok)
_, ok = parseSpecificListenIP("::")
require.False(t, ok)
_, ok = parseSpecificListenIP("not-an-ip")
require.False(t, ok)
}

func TestResolveMediaBindIP(t *testing.T) {
t.Parallel()

loopback := netip.MustParseAddr("127.0.0.1")

t.Run("media_listen_ip wins", func(t *testing.T) {
got := resolveMediaBindIP(&config.Config{
MediaListenIP: "127.0.0.1",
ListenIP: "10.0.0.1",
})
require.Equal(t, loopback, got)
})

t.Run("listen_ip when media_listen_ip empty", func(t *testing.T) {
got := resolveMediaBindIP(&config.Config{
ListenIP: "127.0.0.1",
})
require.Equal(t, loopback, got)
})

t.Run("wildcard listen_ip ignored", func(t *testing.T) {
got := resolveMediaBindIP(&config.Config{
ListenIP: "0.0.0.0",
})
require.False(t, got.IsValid())
})

t.Run("default remains unspecified", func(t *testing.T) {
got := resolveMediaBindIP(&config.Config{})
require.False(t, got.IsValid(), "without explicit listen config RTP must stay on 0.0.0.0")
})

t.Run("nil config", func(t *testing.T) {
got := resolveMediaBindIP(nil)
require.False(t, got.IsValid())
})
}
1 change: 1 addition & 0 deletions pkg/sip/inbound.go
Original file line number Diff line number Diff line change
Expand Up @@ -1115,6 +1115,7 @@ func (c *inboundCall) runMediaConn(tid traceid.ID, offerData []byte, mconf *sipM
logSignalChanges, _ = strconv.ParseBool(featureFlags[signalLoggingFeatureFlag])
mp, err := NewMediaPort(tid, c.log(), c.mon, &MediaOptions{
IP: c.s.sconf.MediaIP,
BindIP: resolveMediaBindIP(c.s.conf),
Ports: conf.RTPPort,
MediaTimeoutInitial: c.s.conf.MediaTimeoutInitial,
MediaTimeout: mconf.MediaTimeout,
Expand Down
7 changes: 6 additions & 1 deletion pkg/sip/media_port.go
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ type MediaConf struct {

type MediaOptions struct {
IP netip.Addr
BindIP netip.Addr // local interface for RTP sockets; zero binds 0.0.0.0
Ports rtcconfig.PortRange
MediaTimeoutInitial time.Duration
MediaTimeout time.Duration
Expand Down Expand Up @@ -389,7 +390,11 @@ func NewMediaPortWith(tid traceid.ID, log logger.Logger, mon *stats.CallMonitor,
}
if conn == nil {
// use an even RTP port (RFC 3550); some gateways misroute media when offered an odd one
c, err := rtp.ListenUDPEvenPortRange(opts.Ports.Start, opts.Ports.End, netip.AddrFrom4([4]byte{0, 0, 0, 0}))
bindIP := opts.BindIP
if !bindIP.IsValid() {
bindIP = netip.AddrFrom4([4]byte{0, 0, 0, 0})
}
c, err := rtp.ListenUDPEvenPortRange(opts.Ports.Start, opts.Ports.End, bindIP)
if err != nil {
return nil, err
}
Expand Down
16 changes: 16 additions & 0 deletions pkg/sip/media_port_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,22 @@ func newTestMediaPort(t testing.TB, provider string) *MediaPort {
return mp
}

func TestMediaPortBindIP(t *testing.T) {
mon := newTestCallMonitor(t)
bindIP := netip.MustParseAddr("127.0.0.1")
mp, err := NewMediaPort(1, logger.GetLogger(), mon, &MediaOptions{
IP: bindIP,
BindIP: bindIP,
Ports: rtcconfig.PortRange{Start: 20000, End: 20020},
}, 8000)
require.NoError(t, err)
t.Cleanup(func() { mp.Close() })

addr, ok := mp.port.LocalAddr().(*net.UDPAddr)
require.True(t, ok)
require.True(t, addr.IP.Equal(net.ParseIP("127.0.0.1")), "RTP socket must bind BindIP, got %v", addr.IP)
}

type testUDPConn struct {
addr netip.AddrPort
closed chan struct{}
Expand Down
1 change: 1 addition & 0 deletions pkg/sip/outbound.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ func (c *Client) newCall(ctx context.Context, tid traceid.ID, conf *config.Confi

call.media, err = NewMediaPort(tid, call.log, call.mon, &MediaOptions{
IP: c.sconf.MediaIP,
BindIP: resolveMediaBindIP(c.conf),
Ports: conf.RTPPort,
MediaTimeoutInitial: c.conf.MediaTimeoutInitial,
MediaTimeout: sipConf.mediaConfig.MediaTimeout,
Expand Down