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
4 changes: 4 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ type Config struct {
DisableRejectedInviteCache bool `yaml:"disable_rejected_invite_cache"`
// AddRecordRoute forces SIP to add Record-Route headers to the responses.
AddRecordRoute bool `yaml:"add_record_route"`
// DisableDNSSRV turns off DNS SRV lookups for outbound destinations, so a
// hostname without an explicit port is resolved with a plain address lookup
// at the transport's default port.
DisableDNSSRV bool `yaml:"disable_dns_srv"`

// AudioDTMF forces SIP to generate audio DTMF tones in addition to digital.
AudioDTMF bool `yaml:"audio_dtmf"`
Expand Down
12 changes: 12 additions & 0 deletions pkg/sip/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ type Client struct {
mon *stats.Monitor

sipCli SIPClient
dns DNSResolver

closing core.Fuse
cmu sync.Mutex
Expand All @@ -88,6 +89,16 @@ func WithGetSipClient(fn GetSipClientFunc) ClientOption {
}
}

// WithDNSResolver overrides the resolver used to locate outbound destinations.
// Defaults to net.DefaultResolver.
func WithDNSResolver(r DNSResolver) ClientOption {
return func(c *Client) {
if r != nil {
c.dns = r
}
}
}

func WithGetRoomClient(fn GetRoomFunc) ClientOption {
return func(c *Client) {
if fn != nil {
Expand All @@ -108,6 +119,7 @@ func NewClient(region string, conf *config.Config, log logger.Logger, mon *stats
getStateHandler: getStateHandler,
getSipClient: DefaultGetSipClientFunc,
getRoom: DefaultGetRoomFunc,
dns: net.DefaultResolver,
activeCalls: make(map[LocalTag]*outboundCall),
}
for _, option := range options {
Expand Down
116 changes: 116 additions & 0 deletions pkg/sip/dns.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// 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 (
"context"
"net"
"net/netip"
"strconv"
"strings"

"github.com/livekit/sipgo/sip"
)

// DNSResolver is the subset of *net.Resolver used to locate a SIP next hop.
// It is an interface so that tests can stub DNS out.
type DNSResolver interface {
LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error)
LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error)
}

var _ DNSResolver = (*net.Resolver)(nil)

// srvLabels returns the RFC 3263 service and protocol labels for a SIP
// transport, i.e. the "sip" and "udp" of an _sip._udp.example.com SRV lookup.
// ok is false for transports that have no SRV mapping.
func srvLabels(transport string) (service, proto string, ok bool) {
switch strings.ToLower(transport) {
case "udp":
return "sip", "udp", true
case "tcp":
return "sip", "tcp", true
case "tls":
return "sips", "tcp", true
case "ws": // RFC 7118
return "sip", "ws", true
case "wss": // RFC 7118
return "sips", "wss", true
}
return "", "", false
}

// resolveNextHop returns the "ip:port" transport destination for a SIP next
// hop, following the DNS procedures of RFC 3263 section 4.
//
// SRV records are only consulted when the URI carries no explicit port
// (RFC 3263 section 4.2): a numeric port, like an IP literal host, means the
// hop has already been selected and DNS must not override it. When the target
// publishes no usable SRV record, resolution falls back to a plain address
// lookup of host at the transport's default port, which is what the transport
// layer would have done on its own.
//
// NAPTR (RFC 3263 section 4.1) is deliberately not implemented: the transport
// is always already known here, either from the trunk configuration or from
// the URI's transport parameter, so there is nothing left for NAPTR to select.
func resolveNextHop(ctx context.Context, r DNSResolver, host string, port int, transport string) (string, error) {
if r == nil {
r = net.DefaultResolver
}
if ip, err := netip.ParseAddr(host); err == nil {
if port == 0 {
port = sip.DefaultPort(transport)
}
return netip.AddrPortFrom(ip, uint16(port)).String(), nil
}
if port != 0 {
return resolveHost(ctx, r, host, port)
}
if service, proto, ok := srvLabels(transport); ok {
if _, srvs, err := r.LookupSRV(ctx, service, proto, host); err == nil {
// LookupSRV already orders records by priority and shuffles them by
// weight, so the first target that resolves is the one to use.
for _, srv := range srvs {
target := strings.TrimSuffix(srv.Target, ".")
if target == "" {
// A lone "." target means "no service here" (RFC 2782). Treat
// it as an unusable record and fall back to an address lookup,
// rather than failing the call outright.
break
}
dest, err := resolveHost(ctx, r, target, int(srv.Port))
if err != nil {
continue // try the next target
}
return dest, nil
}
}
}
return resolveHost(ctx, r, host, sip.DefaultPort(transport))
}

// resolveHost resolves host to a single "ip:port" destination. It picks the
// first address returned, which is the one the resolver considers preferable
// under RFC 6724, matching what net.ResolveIPAddr would have selected.
func resolveHost(ctx context.Context, r DNSResolver, host string, port int) (string, error) {
addrs, err := r.LookupIPAddr(ctx, host)
if err != nil {
return "", err
}
if len(addrs) == 0 {
return "", &net.DNSError{Err: "no such host", Name: host, IsNotFound: true}
}
return net.JoinHostPort(addrs[0].IP.String(), strconv.Itoa(port)), nil
}
150 changes: 150 additions & 0 deletions pkg/sip/dns_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// 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 (
"context"
"net"
"testing"

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

// fakeDNS is a DNSResolver backed by static records. Lookups that miss return
// NXDOMAIN, so a zero fakeDNS resolves nothing and keeps tests off the network.
type fakeDNS struct {
srv map[string][]*net.SRV // keyed by the full name, e.g. "_sip._udp.example.com"
addr map[string][]net.IPAddr // keyed by host
}

func (f fakeDNS) LookupSRV(_ context.Context, service, proto, name string) (string, []*net.SRV, error) {
full := "_" + service + "._" + proto + "." + name
recs, ok := f.srv[full]
if !ok {
return "", nil, &net.DNSError{Err: "no such host", Name: full, IsNotFound: true}
}
return full, recs, nil
}

func (f fakeDNS) LookupIPAddr(_ context.Context, host string) ([]net.IPAddr, error) {
addrs, ok := f.addr[host]
if !ok {
return nil, &net.DNSError{Err: "no such host", Name: host, IsNotFound: true}
}
return addrs, nil
}

func ipAddrs(ips ...string) []net.IPAddr {
out := make([]net.IPAddr, 0, len(ips))
for _, ip := range ips {
out = append(out, net.IPAddr{IP: net.ParseIP(ip)})
}
return out
}

func TestResolveNextHop(t *testing.T) {
dns := fakeDNS{
srv: map[string][]*net.SRV{
"_sip._udp.example.com": {{Target: "udp1.example.com.", Port: 5080}},
"_sip._tcp.example.com": {{Target: "tcp1.example.com.", Port: 5081}},
"_sips._tcp.example.com": {{Target: "tls1.example.com.", Port: 5082}},
// First target does not resolve, so the second one wins.
"_sip._udp.failover.com": {
{Target: "missing.example.com.", Port: 5090},
{Target: "udp1.example.com.", Port: 5091},
},
// RFC 2782 "service decidedly not available at this domain".
"_sip._udp.nosrv.com": {{Target: ".", Port: 0}},
},
addr: map[string][]net.IPAddr{
"example.com": ipAddrs("192.0.2.1"),
"udp1.example.com": ipAddrs("192.0.2.10"),
"tcp1.example.com": ipAddrs("192.0.2.11"),
"tls1.example.com": ipAddrs("192.0.2.12"),
"failover.com": ipAddrs("192.0.2.2"),
"nosrv.com": ipAddrs("192.0.2.3"),
"plain.com": ipAddrs("192.0.2.4", "192.0.2.5"),
"v6.example.com": ipAddrs("2001:db8::1"),
},
}

cases := []struct {
name string
host string
port int
transport string
exp string
expErr bool
}{
{name: "ip literal", host: "192.0.2.1", transport: "UDP", exp: "192.0.2.1:5060"},
{name: "ip literal with port", host: "192.0.2.1", port: 5080, transport: "UDP", exp: "192.0.2.1:5080"},
{name: "ip literal tls default port", host: "192.0.2.1", transport: "TLS", exp: "192.0.2.1:5061"},
{name: "ipv6 literal", host: "2001:db8::1", transport: "UDP", exp: "[2001:db8::1]:5060"},

// An explicit port means the hop is already chosen: RFC 3263 sec 4.2 says
// not to look at SRV, even though example.com publishes records.
{name: "explicit port skips srv", host: "example.com", port: 5070, transport: "UDP", exp: "192.0.2.1:5070"},

{name: "srv udp", host: "example.com", transport: "UDP", exp: "192.0.2.10:5080"},
{name: "srv tcp", host: "example.com", transport: "TCP", exp: "192.0.2.11:5081"},
{name: "srv tls uses _sips._tcp", host: "example.com", transport: "TLS", exp: "192.0.2.12:5082"},

{name: "srv target that does not resolve is skipped", host: "failover.com", transport: "UDP", exp: "192.0.2.10:5091"},
{name: "dot target falls back to address lookup", host: "nosrv.com", transport: "UDP", exp: "192.0.2.3:5060"},

{name: "no srv falls back to address lookup", host: "plain.com", transport: "UDP", exp: "192.0.2.4:5060"},
{name: "no srv tls fallback uses 5061", host: "plain.com", transport: "TLS", exp: "192.0.2.4:5061"},
{name: "ipv6 address record", host: "v6.example.com", transport: "UDP", exp: "[2001:db8::1]:5060"},

{name: "unresolvable", host: "nowhere.com", transport: "UDP", expErr: true},
{name: "unresolvable with explicit port", host: "nowhere.com", port: 5060, transport: "UDP", expErr: true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := resolveNextHop(context.Background(), dns, c.host, c.port, c.transport)
if c.expErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, c.exp, got)
})
}
}

func TestSRVLabels(t *testing.T) {
cases := []struct {
transport string
service string
proto string
ok bool
}{
{transport: "UDP", service: "sip", proto: "udp", ok: true},
{transport: "udp", service: "sip", proto: "udp", ok: true},
{transport: "TCP", service: "sip", proto: "tcp", ok: true},
{transport: "TLS", service: "sips", proto: "tcp", ok: true},
{transport: "WS", service: "sip", proto: "ws", ok: true},
{transport: "WSS", service: "sips", proto: "wss", ok: true},
{transport: "sctp", ok: false},
}
for _, c := range cases {
t.Run(c.transport, func(t *testing.T) {
service, proto, ok := srvLabels(c.transport)
require.Equal(t, c.ok, ok)
require.Equal(t, c.service, service)
require.Equal(t, c.proto, proto)
})
}
}
39 changes: 39 additions & 0 deletions pkg/sip/outbound.go
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,7 @@ type sipOutbound struct {
mu sync.RWMutex
tag RemoteTag
callID string
dest string // resolved next hop, see resolveDest
invite *sip.Request
inviteOk *sip.Response
localSDP []byte // SDP Offer, constrained by the answer
Expand Down Expand Up @@ -1173,6 +1174,10 @@ func (c *sipOutbound) attemptInvite(ctx context.Context, callID sip.CallIDHeader
req.PrependHeader(sip.NewHeader("Route", route))
}

if dest := c.resolveDest(ctx, req); dest != "" {
req.SetDestination(dest)
}

tx, err := c.c.sipCli.TransactionRequest(req)
if err != nil {
return nil, nil, err
Expand Down Expand Up @@ -1211,6 +1216,40 @@ func (c *sipOutbound) attemptInvite(ctx context.Context, callID sip.CallIDHeader
return req, resp, err
}

// resolveDest returns the address the request should be sent to, resolving the
// next hop (the first Route header if there is one, the request URI otherwise)
// with SRV support. Pinning it on the request keeps the transport layer from
// resolving the name itself, and makes the ACK, CANCEL and BYE built from this
// request reach the same host the INVITE did.
//
// The result is cached because Invite retries the INVITE after an auth
// challenge: a second lookup could land on a different SRV target, where the
// nonce from the 401/407 is not valid.
//
// Failures are not fatal. The destination is left unset and the transport layer
// falls back to its own lookup, so an unresolvable host still fails the way it
// used to.
func (c *sipOutbound) resolveDest(ctx context.Context, req *sip.Request) string {
if c.c.conf.DisableDNSSRV {
return ""
}
if c.dest != "" {
return c.dest
}
uri := &req.Recipient
if h := req.Route(); h != nil {
uri = &h.Address
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
dest, err := resolveNextHop(ctx, c.c.dns, uri.Host, uri.Port, req.Transport())
if err != nil {
c.log.Debugw("cannot resolve outbound destination, deferring to transport layer",
"host", uri.Host, "port", uri.Port, "transport", req.Transport(), "error", err)
return ""
}
c.dest = dest
return dest
}

func (c *sipOutbound) WriteRequest(req *sip.Request) error {
return c.c.sipCli.WriteRequest(req)
}
Expand Down
Loading