diff --git a/pkg/config/config.go b/pkg/config/config.go index 8192660d3..16c55cd39 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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"` diff --git a/pkg/sip/client.go b/pkg/sip/client.go index fa64bebdb..2b8099432 100644 --- a/pkg/sip/client.go +++ b/pkg/sip/client.go @@ -67,6 +67,7 @@ type Client struct { mon *stats.Monitor sipCli SIPClient + dns DNSResolver closing core.Fuse cmu sync.Mutex @@ -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 { @@ -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 { diff --git a/pkg/sip/dns.go b/pkg/sip/dns.go new file mode 100644 index 000000000..286911f6d --- /dev/null +++ b/pkg/sip/dns.go @@ -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 +} diff --git a/pkg/sip/dns_test.go b/pkg/sip/dns_test.go new file mode 100644 index 000000000..5f18bc776 --- /dev/null +++ b/pkg/sip/dns_test.go @@ -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) + }) + } +} diff --git a/pkg/sip/outbound.go b/pkg/sip/outbound.go index 89ef94b19..f57862d82 100644 --- a/pkg/sip/outbound.go +++ b/pkg/sip/outbound.go @@ -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 @@ -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 @@ -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 + } + 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) } diff --git a/pkg/sip/outbound_test.go b/pkg/sip/outbound_test.go index e01a1953f..a174a398a 100644 --- a/pkg/sip/outbound_test.go +++ b/pkg/sip/outbound_test.go @@ -17,6 +17,7 @@ package sip import ( "context" "fmt" + "net" "testing" "time" @@ -295,6 +296,91 @@ func TestOutboundACKDestinationAfterInviteResponse(t *testing.T) { }) } +func TestOutboundINVITEUsesSRVDestination(t *testing.T) { + // sip.example.com publishes an SRV record, so the INVITE must go to the SRV + // target and port rather than to the A record on the default port. + dns := fakeDNS{ + srv: map[string][]*net.SRV{ + "_sip._udp." + testInviteTargetHost: {{Target: "edge1.example.com.", Port: 5080}}, + }, + addr: map[string][]net.IPAddr{ + "edge1.example.com": ipAddrs("192.0.2.10"), + testInviteTargetHost: ipAddrs("192.0.2.1"), + }, + } + const srvDest = "192.0.2.10:5080" + + _, tr, ackReq := waitOutboundINVITEAndACK(t, TestClientConfig{DNS: dns}, MinimalCreateSIPParticipantRequest(), func(tr *transactionRequest, resp *sip.Response) { + // Assert here, while the INVITE is in flight: applyInviteResponse may + // rewrite the destination once the response is handled. + require.Equal(t, srvDest, tr.req.Destination()) + // The SRV target only picks the hop; the request URI keeps the hostname. + require.Equal(t, testInviteTargetHost, tr.req.Recipient.Host) + + resp.AppendHeader(&sip.ContactHeader{Address: sip.Uri{Host: testInviteTargetHost, Port: 5060}}) + }) + require.NotNil(t, tr) + require.NotNil(t, ackReq) + + // The ACK follows the INVITE to the same host instead of resolving again. + require.Equal(t, srvDest, ackReq.req.Destination()) +} + +func TestOutboundINVITERouteHeaderIsTheNextHop(t *testing.T) { + // With an outbound proxy configured, the Route header is the next hop, so it + // is the proxy that gets resolved, not the request URI. Route headers are + // added as generic headers, but Route() parses those lazily, so the next hop + // is picked the same way the transport layer picks it. + conf := minimalTestConfig(t) + conf.OutboundRouteHeaders = []string{""} + cfg := TestClientConfig{ + Config: conf, + DNS: fakeDNS{ + srv: map[string][]*net.SRV{ + "_sip._udp.proxy.example.com": {{Target: "edge-proxy.example.com.", Port: 5090}}, + "_sip._udp." + testInviteTargetHost: {{Target: "edge1.example.com.", Port: 5080}}, + }, + addr: map[string][]net.IPAddr{ + "edge-proxy.example.com": ipAddrs("192.0.2.20"), + "edge1.example.com": ipAddrs("192.0.2.10"), + testInviteTargetHost: ipAddrs("192.0.2.1"), + }, + }, + } + + participantReq := MinimalCreateSIPParticipantRequest() + participantReq.FeatureFlags = map[string]string{outboundRouteHeadersFeatureFlag: "true"} + + waitOutboundINVITEAndACK(t, cfg, participantReq, func(tr *transactionRequest, resp *sip.Response) { + require.NotNil(t, tr.req.Route(), "Route header should be visible through the typed accessor") + require.Equal(t, "192.0.2.20:5090", tr.req.Destination()) + require.Equal(t, testInviteTargetHost, tr.req.Recipient.Host) + + resp.AppendHeader(&sip.ContactHeader{Address: sip.Uri{Host: testInviteTargetHost, Port: 5060}}) + }) +} + +func TestOutboundINVITEDNSSRVDisabled(t *testing.T) { + // With SRV lookups disabled the destination is left to the transport layer. + conf := minimalTestConfig(t) + conf.DisableDNSSRV = true + cfg := TestClientConfig{ + Config: conf, + DNS: fakeDNS{ + srv: map[string][]*net.SRV{ + "_sip._udp." + testInviteTargetHost: {{Target: "edge1.example.com.", Port: 5080}}, + }, + addr: map[string][]net.IPAddr{"edge1.example.com": ipAddrs("192.0.2.10")}, + }, + } + + waitOutboundINVITEAndACK(t, cfg, MinimalCreateSIPParticipantRequest(), func(tr *transactionRequest, resp *sip.Response) { + // Destination() falls back to the request URI, so check the pinned field. + require.Empty(t, tr.req.MessageData.Destination()) + resp.AppendHeader(&sip.ContactHeader{Address: sip.Uri{Host: testInviteTargetHost, Port: 5060}}) + }) +} + // sipResponse returns immediately on a cancelled context, sending a CANCEL. func TestSIPResponseCancelReturnsImmediately(t *testing.T) { tx := &testSIPClientTransaction{ diff --git a/pkg/sip/outbound_utilities_test.go b/pkg/sip/outbound_utilities_test.go index 785508075..cc47e51f5 100644 --- a/pkg/sip/outbound_utilities_test.go +++ b/pkg/sip/outbound_utilities_test.go @@ -480,6 +480,28 @@ type TestClientConfig struct { GetSipClient GetSipClientFunc // NewTestClientFunc if nil GetRoom GetRoomFunc // newTestRoom if nil Handler Handler // empty TestHandler if nil + DNS DNSResolver // resolves nothing if nil, so tests stay off the network +} + +// minimalTestConfig returns the config NewOutboundTestClient uses by default. +func minimalTestConfig(t testing.TB) *config.Config { + t.Helper() + localIP, err := config.GetLocalIP() + if err != nil { + t.Fatalf("failed to get local IP: %v", err) + } + return &config.Config{ + NodeID: "test-node", + SIPPort: 5060, + SIPPortListen: 5060, + ListenIP: localIP.String(), + LocalNet: localIP.String() + "/24", + RTPPort: rtcconfig.PortRange{Start: 20000, End: 30000}, + MaxCpuUtilization: 0.99, // Higher threshold for tests to avoid false positives + WsUrl: "ws://localhost:7880", + ApiKey: "test-api-key", + ApiSecret: "test-api-secret-extend-to-32-bytes-minimum", + } } func NewOutboundTestClient(t testing.TB, cfg TestClientConfig) *Client { @@ -489,22 +511,7 @@ func NewOutboundTestClient(t testing.TB, cfg TestClientConfig) *Client { } log := logger.NewTestLogger(t) if cfg.Config == nil { - localIP, err := config.GetLocalIP() - if err != nil { - t.Fatalf("failed to get local IP: %v", err) - } - cfg.Config = &config.Config{ - NodeID: "test-node", - SIPPort: 5060, - SIPPortListen: 5060, - ListenIP: localIP.String(), - LocalNet: localIP.String() + "/24", - RTPPort: rtcconfig.PortRange{Start: 20000, End: 30000}, - MaxCpuUtilization: 0.99, // Higher threshold for tests to avoid false positives - WsUrl: "ws://localhost:7880", - ApiKey: "test-api-key", - ApiSecret: "test-api-secret-extend-to-32-bytes-minimum", - } + cfg.Config = minimalTestConfig(t) } if cfg.Monitor == nil { var err error @@ -543,7 +550,10 @@ func NewOutboundTestClient(t testing.TB, cfg TestClientConfig) *Client { if cfg.Handler == nil { cfg.Handler = &TestHandler{} } - client := NewClient(cfg.Region, cfg.Config, log, cfg.Monitor, cfg.GetIOClient, WithGetSipClient(cfg.GetSipClient), WithGetRoomClient(cfg.GetRoom)) + if cfg.DNS == nil { + cfg.DNS = fakeDNS{} + } + client := NewClient(cfg.Region, cfg.Config, log, cfg.Monitor, cfg.GetIOClient, WithGetSipClient(cfg.GetSipClient), WithGetRoomClient(cfg.GetRoom), WithDNSResolver(cfg.DNS)) client.SetHandler(cfg.Handler) // Set up service config with minimal values