diff --git a/pkg/config/config.go b/pkg/config/config.go index 8192660d..3213db48 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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"` @@ -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 } diff --git a/pkg/config/config_media_listen_ip_test.go b/pkg/config/config_media_listen_ip_test.go new file mode 100644 index 00000000..a0f3420f --- /dev/null +++ b/pkg/config/config_media_listen_ip_test.go @@ -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") + }) +} diff --git a/pkg/sip/config.go b/pkg/sip/config.go index 4099e57b..a6160357 100644 --- a/pkg/sip/config.go +++ b/pkg/sip/config.go @@ -19,6 +19,7 @@ import ( "fmt" "net" "net/netip" + "strings" "time" "github.com/livekit/mediatransportutil/pkg/rtcconfig" @@ -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 +} diff --git a/pkg/sip/config_media_bind_test.go b/pkg/sip/config_media_bind_test.go new file mode 100644 index 00000000..a98a292f --- /dev/null +++ b/pkg/sip/config_media_bind_test.go @@ -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()) + }) +} diff --git a/pkg/sip/inbound.go b/pkg/sip/inbound.go index 8b7f4f77..89ae540d 100644 --- a/pkg/sip/inbound.go +++ b/pkg/sip/inbound.go @@ -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, diff --git a/pkg/sip/media_port.go b/pkg/sip/media_port.go index e6df1d85..40d9c1b6 100644 --- a/pkg/sip/media_port.go +++ b/pkg/sip/media_port.go @@ -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 @@ -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 } diff --git a/pkg/sip/media_port_test.go b/pkg/sip/media_port_test.go index 64b693b4..e4a8dcd8 100644 --- a/pkg/sip/media_port_test.go +++ b/pkg/sip/media_port_test.go @@ -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{} diff --git a/pkg/sip/outbound.go b/pkg/sip/outbound.go index 89d5252c..bc45fe36 100644 --- a/pkg/sip/outbound.go +++ b/pkg/sip/outbound.go @@ -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,